Compare commits

...

18 Commits

Author SHA1 Message Date
Adnan Hajdarevic d97d94537a Add IsNull and Exists types to the Match rule 2021-01-26 22:19:46 +01:00
Adnan Hajdarevic 6184509494 Add build directory to .gitignore 2021-01-26 20:53:07 +01:00
Adnan Hajdarevic b1f69564a3 Merge branch 'development' 2020-12-06 08:42:20 +01:00
Adnan Hajdarevic 159cb4a911 bump version to 2.8.0 2020-12-06 08:42:09 +01:00
Adnan Hajdarević b5af9a3968 Merge pull request #489 from moorereason/iss487-doc-string
Add string parameter example to docs
2020-12-06 08:40:31 +01:00
Adnan Hajdarević 2e4aea4cbc Merge pull request #486 from moorereason/iss439-raw-body
Add option to send raw request body to command
2020-12-06 08:39:55 +01:00
Adnan Hajdarević b6e5b11174 Merge pull request #485 from moorereason/iss234-soft-sig-errors
Add soft signature failure support
2020-12-06 08:39:31 +01:00
Adnan Hajdarević 9dec52c727 Merge pull request #484 from moorereason/iss421-slash-path
Add support for slashes in hook IDs
2020-12-06 08:38:53 +01:00
Cameron Moore f2b536dbad Add string parameter example to docs
Fixes #487
2020-12-05 16:34:49 -06:00
Cameron Moore 62f9c01cab Add option to send raw request body to command
The existing `entire-payload` option sends a JSON representation of the
parsed request body.  Add a new `raw-request-body` source to send the
raw request body.

Fixes #439
2020-11-25 10:20:10 -06:00
Cameron Moore 6d2f26d952 Add soft signature failure support
Add a new trigger-signature-soft-failures option to allow soft signature
failures in Or rules.

Fixes #234
2020-11-24 21:16:57 -06:00
Cameron Moore c2ffd465c4 Add support for slashes in hook IDs
When matching variables in routes, gorilla/mux uses a default pattern of
"[^/]+", thereby prohibiting slashes in variable matching.  Override the
default pattern to remove this restriction.

See https://github.com/gorilla/mux/blob/v1.8.0/regexp.go#L50

Fixes #421
2020-11-24 16:56:54 -06:00
Adnan Hajdarević 3e18a060ae Merge pull request #479 from moorereason/iss312-http-request
Add request source
2020-11-21 18:58:19 +01:00
Cameron Moore 6f5962f8f2 Use strings.ToLower on source name parameters 2020-11-21 10:00:03 -06:00
Cameron Moore 346c761ef6 Add request source
Add "request" source with support for "method" and "remote-addr"
parameters.  Both values are taken from the raw http.Request object.

Fixes #312
2020-11-20 16:32:55 -06:00
Adnan Hajdarević 9c7f8c1ac4 Update README.md 2020-11-05 23:20:36 +01:00
Adnan Hajdarević 7467933680 Merge pull request #469 from Maximization/patch-1
Add guide to the README
2020-10-14 15:31:17 +02:00
Maxim Orlov fd50118712 Add guide to the README 2020-10-13 16:12:01 +02:00
12 changed files with 872 additions and 69 deletions
+1
View File
@@ -3,3 +3,4 @@
coverage
webhook
/test/hookecho
build
+4
View File
@@ -22,6 +22,9 @@ Everything else is the responsibility of the command's author.
If you don't have time to waste configuring, hosting, debugging and maintaining your webhook instance, we offer a __SaaS__ solution that has all of the capabilities webhook provides, plus a lot more, and all that packaged in a nice friendly web interface. If you are interested, find out more at [hookdoo website](https://www.hookdoo.com/?ref=github-webhook-readme). If you have any questions, you can contact us at info@hookdoo.com
#
<a href="https://www.hookdeck.io/?ref=adnanh-webhook"><img src="http://hajdarevic.net/hookdeck-logo.svg" height="17" alt="hookdeck" align="left" /></a> If you need a way of inspecting, monitoring and replaying webhooks without the back and forth troubleshooting, [give Hookdeck a try!](https://www.hookdeck.io/?ref=adnanh-webhook)
# Getting started
## Installation
@@ -129,6 +132,7 @@ Check out [Hook examples page](docs/Hook-Examples.md) for more complex examples
- [Adventures in webhooks](https://medium.com/@draketech/adventures-in-webhooks-2d6584501c62) by [Drake](https://medium.com/@draketech)
- [GitHub pro tips](http://notes.spencerlyon.com/2016/01/04/github-pro-tips/) by [Spencer Lyon](http://notes.spencerlyon.com/)
- [XiaoMi Vacuum + Amazon Button = Dash Cleaning](https://www.instructables.com/id/XiaoMi-Vacuum-Amazon-Button-Dash-Cleaning/) by [c0mmensal](https://www.instructables.com/member/c0mmensal/)
- [Set up Automated Deployments From Github With Webhook](https://maximorlov.com/automated-deployments-from-github-with-webhook/) by [Maxim Orlov](https://twitter.com/_maximization)
- VIDEO: [Gitlab CI/CD configuration using Docker and adnanh/webhook to deploy on VPS - Tutorial #1](https://www.youtube.com/watch?v=Qhn-lXjyrZA&feature=youtu.be) by [Yes! Let's Learn Software Engineering](https://www.youtube.com/channel/UCH4XJf2BZ_52fbf8fOBMF3w)
- ...
- Want to add your own? Open an Issue or create a PR :-)
+1
View File
@@ -22,6 +22,7 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas
* `pass-file-to-command` - specifies a list of entries that will be serialized as a file. Incoming [data](Referencing-Request-Values.md) will be serialized in a request-temporary-file (otherwise parallel calls of the hook would lead to concurrent overwritings of the file). The filename to be addressed within the subsequent script is provided via an environment variable. Use `envname` to specify the name of the environment variable. If `envname` is not provided `HOOK_` and the name used to reference the request value are used. Defining `command-working-directory` will store the file relative to this location, if not provided, the systems temporary file directory will be used. If `base64decode` is true, the incoming binary data will be base 64 decoded prior to storing it into the file. By default the corresponding file will be removed after the webhook exited.
* `trigger-rule` - specifies the rule that will be evaluated in order to determine should the hook be triggered. Check [Hook rules page](Hook-Rules.md) to see the list of valid rules and their usage
* `trigger-rule-mismatch-http-response-code` - specifies the HTTP status code to be returned when the trigger rule is not satisfied
* `trigger-signature-soft-failures` - allow signature validation failures within Or rules; by default, signature failures are treated as errors.
## Examples
Check out [Hook examples page](Hook-Examples.md) for more complex examples of hooks.
+32
View File
@@ -20,6 +20,7 @@ although the examples on this page all use the JSON format.
* [Travis CI webhook](#travis-ci-webhook)
* [XML Payload](#xml-payload)
* [Multipart Form Data](#multipart-form-data)
* [Pass string arguments to command](#pass-string-arguments-to-command)
## Incoming Github webhook
```json
@@ -589,3 +590,34 @@ Content-Disposition: form-data; name="thumb"; filename="thumb.jpg"
```
We key off of the `name` attribute in the `Content-Disposition` value.
## Pass string arguments to command
To pass simple string arguments to a command, use the `string` parameter source.
The following example will pass two static string parameters ("-e 123123") to the
`execute-command` before appending the `pusher.email` value from the payload:
```json
[
{
"id": "webhook",
"execute-command": "/home/adnan/redeploy-go-webhook.sh",
"command-working-directory": "/home/adnan/go",
"pass-arguments-to-command":
[
{
"source": "string",
"name": "-e"
},
{
"source": "string",
"name": "123123"
},
{
"source": "payload",
"name": "pusher.email"
}
]
}
]
```
+19 -3
View File
@@ -1,5 +1,5 @@
# Referencing request values
There are three types of request values:
There are four types of request values:
1. HTTP Request Header values
@@ -19,7 +19,23 @@ There are three types of request values:
}
```
3. Payload (JSON or form-value encoded)
3. HTTP Request parameters
```json
{
"source": "request",
"name": "method"
}
```
```json
{
"source": "request",
"name": "remote-addr"
}
```
4. Payload (JSON or form-value encoded)
```json
{
"source": "payload",
@@ -57,7 +73,7 @@ There are three types of request values:
If the payload contains a key with the specified name "commits.0.commit.id", then the value of that key has priority over the dot-notation referencing.
3. XML Payload
4. XML Payload
Referencing XML payload parameters is much like the JSON examples above, but XML is more complex.
Element attributes are prefixed by a hyphen (`-`).
+65 -20
View File
@@ -35,6 +35,8 @@ const (
SourceQuery string = "url"
SourceQueryAlias string = "query"
SourcePayload string = "payload"
SourceRawRequestBody string = "raw-request-body"
SourceRequest string = "request"
SourceString string = "string"
SourceEntirePayload string = "entire-payload"
SourceEntireQuery string = "entire-query"
@@ -94,6 +96,16 @@ func (e *SignatureError) Error() string {
return fmt.Sprintf("invalid payload signature %s%s", e.Signature, empty)
}
// IsSignatureError returns whether err is of type SignatureError.
func IsSignatureError(err error) bool {
switch err.(type) {
case *SignatureError:
return true
default:
return false
}
}
// ArgumentError describes an invalid argument passed to Hook.
type ArgumentError struct {
Argument Argument
@@ -382,7 +394,7 @@ func GetParameter(s string, params interface{}) (interface{}, error) {
return v, nil
}
// Checked for dotted references
// Check for dotted references
p := strings.SplitN(s, ".", 2)
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
if len(p) > 1 {
@@ -399,23 +411,23 @@ func GetParameter(s string, params interface{}) (interface{}, error) {
// ExtractParameterAsString extracts value from interface{} as string based on
// the passed string. Complex data types are rendered as JSON instead of the Go
// Stringer format.
func ExtractParameterAsString(s string, params interface{}) (string, error) {
func ExtractParameterAsString(s string, params interface{}) (string, interface{}, error) {
pValue, err := GetParameter(s, params)
if err != nil {
return "", err
return "", nil, err
}
switch v := reflect.ValueOf(pValue); v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice:
r, err := json.Marshal(pValue)
if err != nil {
return "", err
return "", pValue, err
}
return string(r), nil
return string(r), r, nil
default:
return fmt.Sprintf("%v", pValue), nil
return fmt.Sprintf("%v", pValue), pValue, nil
}
}
@@ -430,7 +442,7 @@ type Argument struct {
// Get Argument method returns the value for the Argument's key name
// based on the Argument's source
func (ha *Argument) Get(r *Request) (string, error) {
func (ha *Argument) Get(r *Request) (string, interface{}, error) {
var source *map[string]interface{}
key := ha.Name
@@ -438,40 +450,63 @@ func (ha *Argument) Get(r *Request) (string, error) {
case SourceHeader:
source = &r.Headers
key = textproto.CanonicalMIMEHeaderKey(ha.Name)
case SourceQuery, SourceQueryAlias:
source = &r.Query
case SourcePayload:
source = &r.Payload
case SourceString:
return ha.Name, nil
return ha.Name, ha.Name, nil
case SourceRawRequestBody:
return string(r.Body), r.Body, nil
case SourceRequest:
if r == nil || r.RawRequest == nil {
return "", nil, errors.New("request is nil")
}
switch strings.ToLower(ha.Name) {
case "remote-addr":
return r.RawRequest.RemoteAddr, r.RawRequest.RemoteAddr, nil
case "method":
return r.RawRequest.Method, r.RawRequest.Method, nil
default:
return "", nil, fmt.Errorf("unsupported request key: %q", ha.Name)
}
case SourceEntirePayload:
res, err := json.Marshal(&r.Payload)
if err != nil {
return "", err
return "", r.Payload, err
}
return string(res), nil
return string(res), r.Payload, nil
case SourceEntireHeaders:
res, err := json.Marshal(&r.Headers)
if err != nil {
return "", err
return "", r.Headers, err
}
return string(res), nil
return string(res), r.Headers, nil
case SourceEntireQuery:
res, err := json.Marshal(&r.Query)
if err != nil {
return "", err
return "", r.Query, err
}
return string(res), nil
return string(res), r.Query, nil
}
if source != nil {
return ExtractParameterAsString(key, *source)
}
return "", errors.New("no source for value retrieval")
return "", nil, errors.New("no source for value retrieval")
}
// Header is a structure containing header name and it's value
@@ -542,6 +577,7 @@ type Hook struct {
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
TriggerRule *Rules `json:"trigger-rule,omitempty"`
TriggerRuleMismatchHttpResponseCode int `json:"trigger-rule-mismatch-http-response-code,omitempty"`
TriggerSignatureSoftFailures bool `json:"trigger-signature-soft-failures,omitempty"`
IncomingPayloadContentType string `json:"incoming-payload-content-type,omitempty"`
SuccessHttpResponseCode int `json:"success-http-response-code,omitempty"`
HTTPMethods []string `json:"http-methods"`
@@ -553,7 +589,7 @@ func (h *Hook) ParseJSONParameters(r *Request) []error {
errors := make([]error, 0)
for i := range h.JSONStringParameters {
arg, err := h.JSONStringParameters[i].Get(r)
arg, _, err := h.JSONStringParameters[i].Get(r)
if err != nil {
errors = append(errors, &ArgumentError{h.JSONStringParameters[i]})
} else {
@@ -609,7 +645,7 @@ func (h *Hook) ExtractCommandArguments(r *Request) ([]string, []error) {
args = append(args, h.ExecuteCommand)
for i := range h.PassArgumentsToCommand {
arg, err := h.PassArgumentsToCommand[i].Get(r)
arg, _, err := h.PassArgumentsToCommand[i].Get(r)
if err != nil {
args = append(args, "")
errors = append(errors, &ArgumentError{h.PassArgumentsToCommand[i]})
@@ -633,7 +669,7 @@ func (h *Hook) ExtractCommandArgumentsForEnv(r *Request) ([]string, []error) {
args := make([]string, 0)
errors := make([]error, 0)
for i := range h.PassEnvironmentToCommand {
arg, err := h.PassEnvironmentToCommand[i].Get(r)
arg, _, err := h.PassEnvironmentToCommand[i].Get(r)
if err != nil {
errors = append(errors, &ArgumentError{h.PassEnvironmentToCommand[i]})
continue
@@ -669,7 +705,7 @@ func (h *Hook) ExtractCommandArgumentsForFile(r *Request) ([]FileParameter, []er
args := make([]FileParameter, 0)
errors := make([]error, 0)
for i := range h.PassFileToCommand {
arg, err := h.PassFileToCommand[i].Get(r)
arg, _, err := h.PassFileToCommand[i].Get(r)
if err != nil {
errors = append(errors, &ArgumentError{h.PassFileToCommand[i]})
continue
@@ -824,9 +860,11 @@ func (r OrRule) Evaluate(req *Request) (bool, error) {
rv, err := v.Evaluate(req)
if err != nil {
if !IsParameterNodeError(err) {
if !req.AllowSignatureErrors || (req.AllowSignatureErrors && !IsSignatureError(err)) {
return false, err
}
}
}
res = res || rv
if res {
@@ -860,6 +898,8 @@ type MatchRule struct {
const (
MatchValue string = "value"
MatchRegex string = "regex"
MatchIsNull string = "is-null"
MatchExists string = "exists"
MatchHMACSHA1 string = "payload-hmac-sha1"
MatchHMACSHA256 string = "payload-hmac-sha256"
MatchHMACSHA512 string = "payload-hmac-sha512"
@@ -879,13 +919,17 @@ func (r MatchRule) Evaluate(req *Request) (bool, error) {
return CheckScalrSignature(req, r.Secret, true)
}
arg, err := r.Parameter.Get(req)
arg, rawValue, err := r.Parameter.Get(req)
if err == nil {
switch r.Type {
case MatchValue:
return compare(arg, r.Value), nil
case MatchRegex:
return regexp.MatchString(r.Regex, arg)
case MatchIsNull:
return rawValue == nil, nil
case MatchExists:
return true, nil
case MatchHashSHA1:
log.Print(`warn: use of deprecated option payload-hash-sha1; use payload-hmac-sha1 instead`)
fallthrough
@@ -906,6 +950,7 @@ func (r MatchRule) Evaluate(req *Request) (bool, error) {
return err == nil, err
}
}
return false, err
}
+14 -10
View File
@@ -245,7 +245,7 @@ var extractParameterTests = []struct {
func TestExtractParameter(t *testing.T) {
for _, tt := range extractParameterTests {
value, err := ExtractParameterAsString(tt.s, tt.params)
value, _, err := ExtractParameterAsString(tt.s, tt.params)
if (err == nil) != tt.ok || value != tt.value {
t.Errorf("failed to extract parameter %q:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%v}", tt.s, tt.value, tt.ok, value, err)
}
@@ -255,18 +255,21 @@ func TestExtractParameter(t *testing.T) {
var argumentGetTests = []struct {
source, name string
headers, query, payload map[string]interface{}
request *http.Request
value string
ok bool
}{
{"header", "a", map[string]interface{}{"A": "z"}, nil, nil, "z", true},
{"url", "a", nil, map[string]interface{}{"a": "z"}, nil, "z", true},
{"payload", "a", nil, nil, map[string]interface{}{"a": "z"}, "z", true},
{"string", "a", nil, nil, map[string]interface{}{"a": "z"}, "a", true},
{"header", "a", map[string]interface{}{"A": "z"}, nil, nil, nil, "z", true},
{"url", "a", nil, map[string]interface{}{"a": "z"}, nil, nil, "z", true},
{"payload", "a", nil, nil, map[string]interface{}{"a": "z"}, nil, "z", true},
{"request", "METHOD", nil, nil, map[string]interface{}{"a": "z"}, &http.Request{Method: "POST", RemoteAddr: "127.0.0.1:1234"}, "POST", true},
{"request", "remote-addr", nil, nil, map[string]interface{}{"a": "z"}, &http.Request{Method: "POST", RemoteAddr: "127.0.0.1:1234"}, "127.0.0.1:1234", true},
{"string", "a", nil, nil, map[string]interface{}{"a": "z"}, nil, "a", true},
// failures
{"header", "a", nil, map[string]interface{}{"a": "z"}, map[string]interface{}{"a": "z"}, "", false}, // nil headers
{"url", "a", map[string]interface{}{"A": "z"}, nil, map[string]interface{}{"a": "z"}, "", false}, // nil query
{"payload", "a", map[string]interface{}{"A": "z"}, map[string]interface{}{"a": "z"}, nil, "", false}, // nil payload
{"foo", "a", map[string]interface{}{"A": "z"}, nil, nil, "", false}, // invalid source
{"header", "a", nil, map[string]interface{}{"a": "z"}, map[string]interface{}{"a": "z"}, nil, "", false}, // nil headers
{"url", "a", map[string]interface{}{"A": "z"}, nil, map[string]interface{}{"a": "z"}, nil, "", false}, // nil query
{"payload", "a", map[string]interface{}{"A": "z"}, map[string]interface{}{"a": "z"}, nil, nil, "", false}, // nil payload
{"foo", "a", map[string]interface{}{"A": "z"}, nil, nil, nil, "", false}, // invalid source
}
func TestArgumentGet(t *testing.T) {
@@ -276,8 +279,9 @@ func TestArgumentGet(t *testing.T) {
Headers: tt.headers,
Query: tt.query,
Payload: tt.payload,
RawRequest: tt.request,
}
value, err := a.Get(r)
value, _, err := a.Get(r)
if (err == nil) != tt.ok || value != tt.value {
t.Errorf("failed to get {%q, %q}:\nexpected {value:%#v, ok:%#v},\ngot {value:%#v, err:%v}", tt.source, tt.name, tt.value, tt.ok, value, err)
}
+3
View File
@@ -33,6 +33,9 @@ type Request struct {
// The underlying HTTP request.
RawRequest *http.Request
// Treat signature errors as simple validate failures.
AllowSignatureErrors bool
}
func (r *Request) ParseJSONPayload() error {
+185
View File
@@ -55,6 +55,149 @@
]
}
},
{
"id": "github-multi-sig",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"http-methods": ["Post "],
"include-command-output-in-response": true,
"trigger-rule-mismatch-http-response-code": 400,
"trigger-signature-soft-failures": true,
"pass-environment-to-command":
[
{
"source": "payload",
"name": "head_commit.timestamp"
}
],
"pass-arguments-to-command":
[
{
"source": "payload",
"name": "head_commit.id"
},
{
"source": "payload",
"name": "head_commit.author.email"
}
],
"trigger-rule":
{
"and":
[
"or":
[
{
"match":
{
"type": "payload-hmac-sha1",
"secret": "mysecretFAIL",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
},
{
"match":
{
"type": "payload-hmac-sha1",
"secret": "mysecret",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
}
],
{
"match":
{
"type": "value",
"value": "refs/heads/master",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}
]
}
},
{
"id": "github-multi-sig-fail",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"http-methods": ["Post "],
"include-command-output-in-response": true,
"trigger-rule-mismatch-http-response-code": 400,
"pass-environment-to-command":
[
{
"source": "payload",
"name": "head_commit.timestamp"
}
],
"pass-arguments-to-command":
[
{
"source": "payload",
"name": "head_commit.id"
},
{
"source": "payload",
"name": "head_commit.author.email"
}
],
"trigger-rule":
{
"and":
[
"or":
[
{
"match":
{
"type": "payload-hmac-sha1",
"secret": "mysecretFAIL",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
},
{
"match":
{
"type": "payload-hmac-sha1",
"secret": "mysecret",
"parameter":
{
"source": "header",
"name": "X-Hub-Signature"
}
}
}
],
{
"match":
{
"type": "value",
"value": "refs/heads/master",
"parameter":
{
"source": "payload",
"name": "ref"
}
}
}
]
}
},
{
"id": "bitbucket",
"execute-command": "{{ .Hookecho }}",
@@ -168,6 +311,17 @@
],
}
},
{
"id": "txt-raw",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"include-command-output-in-response": true,
"pass-arguments-to-command": [
{
"source": "raw-request-body"
}
]
},
{
"id": "sendgrid",
"execute-command": "{{ .Hookecho }}",
@@ -184,6 +338,22 @@
}
}
},
{
"id": "sendgrid/dir",
"execute-command": "{{ .Hookecho }}",
"command-working-directory": "/",
"response-message": "success",
"trigger-rule": {
"match": {
"type": "value",
"parameter": {
"source": "payload",
"name": "root.0.event"
},
"value": "it worked!"
}
}
},
{
"id": "plex",
"execute-command": "{{ .Hookecho }}",
@@ -252,6 +422,21 @@
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true
},
{
"id": "request-source",
"pass-arguments-to-command": [
{
"source": "request",
"name": "method"
},
{
"source": "request",
"name": "remote-addr"
}
],
"execute-command": "{{ .Hookecho }}",
"include-command-output-in-response": true
},
{
"id": "static-params-ok",
"execute-command": "{{ .Hookecho }}",
+103
View File
@@ -28,6 +28,81 @@
name: head_commit.timestamp
command-working-directory: /
- id: github-multi-sig
http-methods:
- "Post "
trigger-rule:
and:
- or:
- match:
parameter:
source: header
name: X-Hub-Signature
secret: mysecretFAIL
type: payload-hmac-sha1
- match:
parameter:
source: header
name: X-Hub-Signature
secret: mysecret
type: payload-hmac-sha1
- match:
parameter:
source: payload
name: ref
type: value
value: refs/heads/master
include-command-output-in-response: true
trigger-rule-mismatch-http-response-code: 400
trigger-signature-soft-failures: true
execute-command: '{{ .Hookecho }}'
pass-arguments-to-command:
- source: payload
name: head_commit.id
- source: payload
name: head_commit.author.email
pass-environment-to-command:
- source: payload
name: head_commit.timestamp
command-working-directory: /
- id: github-multi-sig-fail
http-methods:
- "Post "
trigger-rule:
and:
- or:
- match:
parameter:
source: header
name: X-Hub-Signature
secret: mysecretFAIL
type: payload-hmac-sha1
- match:
parameter:
source: header
name: X-Hub-Signature
secret: mysecret
type: payload-hmac-sha1
- match:
parameter:
source: payload
name: ref
type: value
value: refs/heads/master
include-command-output-in-response: true
trigger-rule-mismatch-http-response-code: 400
execute-command: '{{ .Hookecho }}'
pass-arguments-to-command:
- source: payload
name: head_commit.id
- source: payload
name: head_commit.author.email
pass-environment-to-command:
- source: payload
name: head_commit.timestamp
command-working-directory: /
- id: bitbucket
trigger-rule:
and:
@@ -97,6 +172,13 @@
name: "app.messages.message.#text"
value: "Hello!!"
- id: txt-raw
execute-command: '{{ .Hookecho }}'
command-working-directory: /
include-command-output-in-response: true
pass-arguments-to-command:
- source: raw-request-body
- id: sendgrid
execute-command: '{{ .Hookecho }}'
command-working-directory: /
@@ -109,6 +191,18 @@
name: root.0.event
value: processed
- id: sendgrid/dir
execute-command: '{{ .Hookecho }}'
command-working-directory: /
response-message: success
trigger-rule:
match:
type: value
parameter:
source: payload
name: root.0.event
value: it worked!
- id: plex
trigger-rule:
match:
@@ -152,6 +246,15 @@
include-command-output-in-response: true
include-command-output-in-response-on-error: true
- id: request-source
pass-arguments-to-command:
- source: request
name: method
- source: request
name: remote-addr
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
- id: static-params-ok
execute-command: '{{ .Hookecho }}'
include-command-output-in-response: true
+22 -8
View File
@@ -25,7 +25,7 @@ import (
)
const (
version = "2.7.0"
version = "2.8.0"
)
var (
@@ -262,7 +262,7 @@ func main() {
// Clean up input
*httpMethods = strings.ToUpper(strings.ReplaceAll(*httpMethods, " ", ""))
hooksURL := makeURL(hooksURLPrefix)
hooksURL := makeRoutePattern(hooksURLPrefix)
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprint(w, "OK")
@@ -278,7 +278,7 @@ func main() {
// Serve HTTP
if !*secure {
log.Printf("serving hooks on http://%s%s", addr, hooksURL)
log.Printf("serving hooks on http://%s%s", addr, makeHumanPattern(hooksURLPrefix))
log.Print(svr.Serve(ln))
return
@@ -293,7 +293,7 @@ func main() {
}
svr.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) // disable http/2
log.Printf("serving hooks on https://%s%s", addr, hooksURL)
log.Printf("serving hooks on https://%s%s", addr, makeHumanPattern(hooksURLPrefix))
log.Print(svr.ServeTLS(ln, *cert, *key))
}
@@ -480,6 +480,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
if matchedHook.TriggerRule == nil {
ok = true
} else {
// Save signature soft failures option in request for evaluators
req.AllowSignatureErrors = matchedHook.TriggerSignatureSoftFailures
ok, err = matchedHook.TriggerRule.Evaluate(req)
if err != nil {
if !hook.IsParameterNodeError(err) {
@@ -763,10 +766,21 @@ func valuesToMap(values map[string][]string) map[string]interface{} {
return ret
}
// makeURL builds a hook URL with or without a prefix.
func makeURL(prefix *string) string {
// makeRoutePattern builds a pattern matching URL for the mux.
func makeRoutePattern(prefix *string) string {
return makeBaseURL(prefix) + "/{id:.*}"
}
// makeHumanPattern builds a human-friendly URL for display.
func makeHumanPattern(prefix *string) string {
return makeBaseURL(prefix) + "/{id}"
}
// makeBaseURL creates the base URL before any mux pattern matching.
func makeBaseURL(prefix *string) string {
if prefix == nil || *prefix == "" {
return "/{id}"
return ""
}
return "/" + *prefix + "/{id}"
return "/" + *prefix
}
+408 -13
View File
@@ -129,9 +129,21 @@ func TestWebhook(t *testing.T) {
t.Errorf("POST %q: failed to ready body: %s", tt.desc, err)
}
if res.StatusCode != tt.respStatus || string(body) != tt.respBody {
// Test body
{
var bodyFailed bool
if tt.bodyIsRE {
bodyFailed = string(body) == tt.respBody
} else {
r := regexp.MustCompile(tt.respBody)
bodyFailed = !r.Match(body)
}
if res.StatusCode != tt.respStatus || bodyFailed {
t.Errorf("failed %q (id: %s):\nexpected status: %#v, response: %s\ngot status: %#v, response: %s\ncommand output:\n%s\n", tt.desc, tt.id, tt.respStatus, tt.respBody, res.StatusCode, body, b)
}
}
if tt.logMatch == "" {
return
@@ -303,6 +315,7 @@ var hookHandlerTests = []struct {
headers map[string]string
contentType string
body string
bodyIsRE bool
respStatus int
respBody string
@@ -459,12 +472,327 @@ var hookHandlerTests = []struct {
"watchers":1
}
}`,
false,
http.StatusOK,
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
`,
``,
},
{
"github-multi-sig",
"github-multi-sig",
nil,
"POST",
map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"},
"application/json",
`{
"after":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a",
"commits":[
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89",
"message":"Test",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T13:50:07-08:00",
"url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89"
},
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"36c5f2243ed24de58284a96f2a643bed8c028658",
"message":"This is me testing the windows client.",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T14:07:13-08:00",
"url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658"
},
{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
}
],
"compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a",
"created":false,
"deleted":false,
"forced":false,
"head_commit":{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
},
"pusher":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian"
},
"ref":"refs/heads/master",
"repository":{
"created_at":1332977768,
"description":"",
"fork":false,
"forks":0,
"has_downloads":true,
"has_issues":true,
"has_wiki":true,
"homepage":"",
"id":3860742,
"language":"Ruby",
"master_branch":"master",
"name":"testing",
"open_issues":2,
"owner":{
"email":"lolwut@noway.biz",
"name":"octokitty"
},
"private":false,
"pushed_at":1363295520,
"size":2156,
"stargazers":1,
"url":"https://github.com/octokitty/testing",
"watchers":1
}
}`,
false,
http.StatusOK,
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
`,
``,
},
{
"github-multi-sig-fail",
"github-multi-sig-fail",
nil,
"POST",
map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"},
"application/json",
`{
"after":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a",
"commits":[
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89",
"message":"Test",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T13:50:07-08:00",
"url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89"
},
{
"added":[
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"36c5f2243ed24de58284a96f2a643bed8c028658",
"message":"This is me testing the windows client.",
"modified":[
"README.md"
],
"removed":[
],
"timestamp":"2013-02-22T14:07:13-08:00",
"url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658"
},
{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
}
],
"compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a",
"created":false,
"deleted":false,
"forced":false,
"head_commit":{
"added":[
"words/madame-bovary.txt"
],
"author":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"committer":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian",
"username":"octokitty"
},
"distinct":true,
"id":"1481a2de7b2a7d02428ad93446ab166be7793fbb",
"message":"Rename madame-bovary.txt to words/madame-bovary.txt",
"modified":[
],
"removed":[
"madame-bovary.txt"
],
"timestamp":"2013-03-12T08:14:29-07:00",
"url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb"
},
"pusher":{
"email":"lolwut@noway.biz",
"name":"Garen Torikian"
},
"ref":"refs/heads/master",
"repository":{
"created_at":1332977768,
"description":"",
"fork":false,
"forks":0,
"has_downloads":true,
"has_issues":true,
"has_wiki":true,
"homepage":"",
"id":3860742,
"language":"Ruby",
"master_branch":"master",
"name":"testing",
"open_issues":2,
"owner":{
"email":"lolwut@noway.biz",
"name":"octokitty"
},
"private":false,
"pushed_at":1363295520,
"size":2156,
"stargazers":1,
"url":"https://github.com/octokitty/testing",
"watchers":1
}
}`,
false,
http.StatusInternalServerError,
`Error occurred while evaluating hook rules.`,
``,
},
{
"bitbucket", // bitbucket sends their payload using uriencoded params.
"bitbucket",
@@ -473,6 +801,7 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
nil,
"application/x-www-form-urlencoded",
`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 <marcus@somedomain.com>","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"}`,
false,
http.StatusOK,
`success`,
``,
@@ -526,6 +855,7 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
],
"total_commits_count": 4
}`,
false,
http.StatusOK,
`arg: b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com
`,
@@ -547,10 +877,30 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
<message id="1" from_user="1" to_user="2">Hello!!</message>
</messages>
</app>`,
false,
http.StatusOK,
`success`,
``,
},
{
"txt-raw",
"txt-raw",
nil,
"POST",
map[string]string{"Content-Type": "text/plain"},
"text/plain",
`# FOO
blah
blah`,
false,
http.StatusOK,
`# FOO
blah
blah`,
``,
},
{
"payload-json-array",
"sendgrid",
@@ -569,6 +919,30 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
"sg_message_id": "sg_message_id"
}
]`,
false,
http.StatusOK,
`success`,
``,
},
{
"slash-in-hook-id",
"sendgrid/dir",
nil,
"POST",
nil,
"application/json",
`[
{
"email": "example@test.com",
"timestamp": 1513299569,
"smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
"event": "it worked!",
"category": "cat facts",
"sg_event_id": "sg_event_id",
"sg_message_id": "sg_message_id"
}
]`,
false,
http.StatusOK,
`success`,
``,
@@ -601,6 +975,7 @@ Content-Transfer-Encoding: binary
binary data
--xxx--`,
false,
http.StatusOK,
`success`,
``,
@@ -614,6 +989,7 @@ binary data
nil,
"application/json",
`{"exists": 1}`,
false,
http.StatusOK,
`success`,
``,
@@ -627,6 +1003,7 @@ binary data
nil,
"application/json",
`{"exists": 1}`,
false,
http.StatusOK,
`Hook rules were not satisfied.`,
`parameter node not found`,
@@ -668,6 +1045,7 @@ binary data
},
"ref":"refs/heads/master"
}`,
false,
http.StatusOK,
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
@@ -710,6 +1088,7 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
},
"ref":"refs/heads/master"
}`,
false,
http.StatusOK,
`arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb lolwut@noway.biz
`,
@@ -724,34 +1103,50 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
map[string]string{"X-Hub-Signature": "33f9d709782f62b8b4a0178586c65ab098a39fe2"},
"application/json",
``,
false,
http.StatusOK,
``,
``,
},
{
"request-source",
"request-source",
nil,
"POST",
map[string]string{"X-Hub-Signature": "33f9d709782f62b8b4a0178586c65ab098a39fe2"},
"application/json",
`{}`,
true,
http.StatusOK,
`arg: POST 127.0.0.1:.*
`,
``,
},
// test with disallowed global HTTP method
{"global disallowed method", "bitbucket", []string{"Post "}, "GET", nil, `{}`, "application/json", http.StatusMethodNotAllowed, ``, ``},
{"global disallowed method", "bitbucket", []string{"Post "}, "GET", nil, `{}`, "application/json", false, http.StatusMethodNotAllowed, ``, ``},
// test with disallowed HTTP method
{"disallowed method", "github", nil, "Get", nil, `{}`, "application/json", http.StatusMethodNotAllowed, ``, ``},
{"disallowed method", "github", nil, "Get", nil, `{}`, "application/json", false, http.StatusMethodNotAllowed, ``, ``},
// test with custom return code
{"empty payload", "github", nil, "POST", nil, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, ``},
{"empty payload", "github", nil, "POST", nil, "application/json", `{}`, false, http.StatusBadRequest, `Hook rules were not satisfied.`, ``},
// test with custom invalid http code, should default to 200 OK
{"empty payload", "bitbucket", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
{"empty payload", "bitbucket", nil, "POST", nil, "application/json", `{}`, false, http.StatusOK, `Hook rules were not satisfied.`, ``},
// test with no configured http return code, should default to 200 OK
{"empty payload", "gitlab", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `Hook rules were not satisfied.`, ``},
{"empty payload", "gitlab", nil, "POST", nil, "application/json", `{}`, false, http.StatusOK, `Hook rules were not satisfied.`, ``},
// test capturing command output
{"don't capture output on success by default", "capture-command-output-on-success-not-by-default", nil, "POST", nil, "application/json", `{}`, http.StatusOK, ``, ``},
{"capture output on success with flag set", "capture-command-output-on-success-yes-with-flag", nil, "POST", nil, "application/json", `{}`, http.StatusOK, `arg: exit=0
{"don't capture output on success by default", "capture-command-output-on-success-not-by-default", nil, "POST", nil, "application/json", `{}`, false, http.StatusOK, ``, ``},
{"capture output on success with flag set", "capture-command-output-on-success-yes-with-flag", nil, "POST", nil, "application/json", `{}`, false, http.StatusOK, `arg: exit=0
`, ``},
{"don't capture output on error by default", "capture-command-output-on-error-not-by-default", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, `Error occurred while executing the hook's command. Please check your logs for more details.`, ``},
{"capture output on error with extra flag set", "capture-command-output-on-error-yes-with-extra-flag", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, `arg: exit=1
{"don't capture output on error by default", "capture-command-output-on-error-not-by-default", nil, "POST", nil, "application/json", `{}`, false, http.StatusInternalServerError, `Error occurred while executing the hook's command. Please check your logs for more details.`, ``},
{"capture output on error with extra flag set", "capture-command-output-on-error-yes-with-extra-flag", nil, "POST", nil, "application/json", `{}`, false, http.StatusInternalServerError, `arg: exit=1
`, ``},
// Check logs
{"static params should pass", "static-params-ok", nil, "POST", nil, "application/json", `{}`, http.StatusOK, "arg: passed\n", `(?s)command output: arg: passed`},
{"command with space logs warning", "warn-on-space", nil, "POST", nil, "application/json", `{}`, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)error in exec:.*use 'pass[-]arguments[-]to[-]command' to specify args`},
{"unsupported content type error", "github", nil, "POST", map[string]string{"Content-Type": "nonexistent/format"}, "application/json", `{}`, http.StatusBadRequest, `Hook rules were not satisfied.`, `(?s)error parsing body payload due to unsupported content type header:`},
{"static params should pass", "static-params-ok", nil, "POST", nil, "application/json", `{}`, false, http.StatusOK, "arg: passed\n", `(?s)command output: arg: passed`},
{"command with space logs warning", "warn-on-space", nil, "POST", nil, "application/json", `{}`, false, http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details.", `(?s)error in exec:.*use 'pass[-]arguments[-]to[-]command' to specify args`},
{"unsupported content type error", "github", nil, "POST", map[string]string{"Content-Type": "nonexistent/format"}, "application/json", `{}`, false, http.StatusBadRequest, `Hook rules were not satisfied.`, `(?s)error parsing body payload due to unsupported content type header:`},
}
// buffer provides a concurrency-safe bytes.Buffer to tests above.