diff --git a/README.md b/README.md index a439590..900bd72 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Hooks are defined using JSON format. The _hooks file_ must contain an array of J { "id": "hook-1", "command": "OS command to be executed when the hook gets triggered", + "args": [ + "ref", + "repository.owner.name" + ], "cwd": "current working directory under which the specified command will be executed (optional, defaults to the directory where the binary resides)", "secret": "secret key used to compute the hash of the payload (optional)", "trigger-rule": @@ -138,7 +142,6 @@ All hooks are served under the `http://ip:port/hook/:id`, where the `:id` corres Visiting `http://ip:port` will show version, uptime and number of hooks the webhook is serving. ## Todo -* Add support for passing parameters from payload to the command that gets executed as part of the hook * Add support for ip white/black listing * Add "match-regex" rule * ??? diff --git a/helpers/helpers.go b/helpers/helpers.go new file mode 100644 index 0000000..df9d070 --- /dev/null +++ b/helpers/helpers.go @@ -0,0 +1,70 @@ +package helpers + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "fmt" + "net/url" + "reflect" + "strconv" + "strings" +) + +// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload +func CheckPayloadSignature(payload []byte, secret string, signature string) (string, bool) { + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write(payload) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + return expectedMAC, hmac.Equal([]byte(signature), []byte(expectedMAC)) +} + +// FormValuesToMap converts url.Values to a map[string]interface{} object +func FormValuesToMap(formValues url.Values) map[string]interface{} { + ret := make(map[string]interface{}) + + for key, value := range formValues { + if len(value) > 0 { + ret[key] = value[0] + } + } + + return ret +} + +// ExtractJSONParameter extracts value from payload based on the passed string +func ExtractJSONParameter(s string, params interface{}) (string, bool) { + var p []string + + if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice { + if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 { + + if p = strings.SplitN(s, ".", 3); len(p) > 3 { + index, err := strconv.ParseInt(p[1], 10, 64) + + if err != nil { + return "", false + } else if paramsValueSliceLength <= int(index) { + return "", false + } + + return ExtractJSONParameter(p[2], params.([]map[string]interface{})[index]) + } + } + + return "", false + } + + if p = strings.SplitN(s, ".", 2); len(p) > 1 { + if pValue, ok := params.(map[string]interface{})[p[0]]; ok { + return ExtractJSONParameter(p[1], pValue) + } + } else { + if pValue, ok := params.(map[string]interface{})[p[0]]; ok { + return fmt.Sprintf("%v", pValue), true + } + } + + return "", false +} diff --git a/hooks/hooks.go b/hooks/hooks.go index 822d48c..5ed313c 100644 --- a/hooks/hooks.go +++ b/hooks/hooks.go @@ -3,7 +3,9 @@ package hooks import ( "encoding/json" "io/ioutil" + "net/url" + "github.com/adnanh/webhook/helpers" "github.com/adnanh/webhook/rules" ) @@ -25,6 +27,40 @@ type Hooks struct { list []Hook } +// ParseFormArgs gets arguments from the Form payload that should be passed to the command +func (h *Hook) ParseFormArgs(form url.Values) []string { + var args = make([]string, len(h.Args)) + + args = append(args, h.Command) + + for i := range h.Args { + if arg := form[h.Args[i]]; len(arg) > 0 { + args = append(args, arg[0]) + } else { + args = append(args, "") + } + } + + return args +} + +// ParseJSONArgs gets arguments from the JSON payload that should be passed to the command +func (h *Hook) ParseJSONArgs(payload interface{}) []string { + var args = make([]string, len(h.Args)) + + args = append(args, h.Command) + + for i := range h.Args { + if arg, ok := helpers.ExtractJSONParameter(h.Args[i], payload); ok { + args = append(args, arg) + } else { + args = append(args, "") + } + } + + return args +} + // UnmarshalJSON implementation for a single hook func (h *Hook) UnmarshalJSON(j []byte) error { m := make(map[string]interface{}) diff --git a/rules/rules.go b/rules/rules.go index 2952cb0..5e84b71 100644 --- a/rules/rules.go +++ b/rules/rules.go @@ -2,10 +2,8 @@ package rules import ( "encoding/json" - "fmt" - "reflect" - "strconv" - "strings" + + "github.com/adnanh/webhook/helpers" ) // Rule interface @@ -79,45 +77,10 @@ func (r NotRule) Evaluate(params interface{}) bool { return !r.SubRule.Evaluate(params) } -func extract(s string, params interface{}) (string, bool) { - var p []string - - if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice { - if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 { - - if p = strings.SplitN(s, ".", 3); len(p) > 3 { - index, err := strconv.ParseInt(p[1], 10, 64) - - if err != nil { - return "", false - } else if paramsValueSliceLength <= int(index) { - return "", false - } - - return extract(p[2], params.([]map[string]interface{})[index]) - } - } - - return "", false - } - - if p = strings.SplitN(s, ".", 2); len(p) > 1 { - if pValue, ok := params.(map[string]interface{})[p[0]]; ok { - return extract(p[1], pValue) - } - } else { - if pValue, ok := params.(map[string]interface{})[p[0]]; ok { - return fmt.Sprintf("%v", pValue), true - } - } - - return "", false -} - // Evaluate MatchRule will return true if and only if the MatchParameter.Parameter // named property value in supplied params matches the MatchParameter.Value func (r MatchRule) Evaluate(params interface{}) bool { - if v, ok := extract(r.MatchParameter.Parameter, params); ok { + if v, ok := helpers.ExtractJSONParameter(r.MatchParameter.Parameter, params); ok { return v == r.MatchParameter.Value } diff --git a/webhook.go b/webhook.go index 46a51f2..9a6519e 100644 --- a/webhook.go +++ b/webhook.go @@ -1,7 +1,6 @@ package main import ( - "encoding/hex" "encoding/json" "flag" "fmt" @@ -12,13 +11,11 @@ import ( "strings" "time" + "github.com/adnanh/webhook/helpers" "github.com/adnanh/webhook/hooks" "github.com/go-martini/martini" - "crypto/hmac" - "crypto/sha1" - l4g "code.google.com/p/log4go" ) @@ -71,48 +68,35 @@ func rootHandler() string { return fmt.Sprintf("webhook %s running for %s serving %d hook(s)\n", version, time.Since(appStart).String(), webhooks.Count()) } -func githubHandler(id string, body []byte, signature string, params interface{}) { - if hook := webhooks.Match(id, params); hook != nil { +func jsonHandler(id string, body []byte, signature string, payload interface{}) { + if hook := webhooks.Match(id, payload); hook != nil { if hook.Secret != "" { if signature == "" { l4g.Error("Hook %s got matched and contains the secret, but the request didn't contain any signature.", hook.ID) return } - mac := hmac.New(sha1.New, []byte(hook.Secret)) - mac.Write(body) - expectedMAC := hex.EncodeToString(mac.Sum(nil)) - - if !hmac.Equal([]byte(signature), []byte(expectedMAC)) { + if expectedMAC, ok := helpers.CheckPayloadSignature(body, hook.Secret, signature); ok { l4g.Error("Hook %s got matched and contains the secret, but the request contained invalid signature. Expected %s, got %s.", hook.ID, expectedMAC, signature) return } } cmd := exec.Command(hook.Command) + cmd.Args = hook.ParseJSONArgs(payload) cmd.Dir = hook.Cwd out, err := cmd.Output() - l4g.Info("Hook %s triggered successfully! Command output:\n%s\nError: %+v", hook.ID, out, err) + l4g.Info("Hook %s triggered successfully! Command output:\n%s\n%+v", hook.ID, out, err) } } -func defaultPostHookHandler(id string, formValues url.Values) { - if hook := webhooks.Match(id, make(map[string]interface{})); hook != nil { - args := make([]string, 0) - args = append(args, hook.Command) - for i := range hook.Args { - if arg := formValues[hook.Args[i]]; len(arg) > 0 { - args = append(args, arg[0]) - } - } - +func formHandler(id string, formValues url.Values) { + if hook := webhooks.Match(id, helpers.FormValuesToMap(formValues)); hook != nil { cmd := exec.Command(hook.Command) - cmd.Args = args + cmd.Args = hook.ParseFormArgs(formValues) cmd.Dir = hook.Cwd - out, err := cmd.Output() - - l4g.Info("Hook %s triggered successfully! Command output:\n%s\nError: %+v", hook.ID, out, err) + l4g.Info("Hook %s triggered successfully! Command output:\n%s\n%+v", hook.ID, out, err) } } @@ -136,18 +120,18 @@ func hookHandler(req *http.Request, params martini.Params) string { l4g.Warn("Error occurred while trying to parse the payload as JSON: %s", err) } - githubSignature := "" - - if len(req.Header.Get("X-Hub-Signature")) > 5 { - githubSignature = req.Header.Get("X-Hub-Signature")[5:] - } + payloadSignature := "" if strings.Contains(req.Header.Get("User-Agent"), "Github") { - go githubHandler(params["id"], body, githubSignature, payloadJSON) + if len(req.Header.Get("X-Hub-Signature")) > 5 { + payloadSignature = req.Header.Get("X-Hub-Signature")[5:] + } + + go jsonHandler(params["id"], body, payloadSignature, payloadJSON) } } else { req.ParseForm() - go defaultPostHookHandler(params["id"], req.Form) + go formHandler(params["id"], req.Form) } return "Got it, thanks. :-)"