webhook/hook/hook.go
Hugo Rosnet 1db3be532b dir-feat: Make loading from directory recursively possible
The commit adds the following modifications:
 * Possibility to load hooks recursively from a directory
 * Extraction of the loadinghook method in webhook.go
 * Tests done on a fairly complex tree of directory

Allowing hooks to be loaded from a directory is a nice feature, as it
allows a better organisation/readibility of the hooks that we have.

The LoadFromDir method rely on LoadFromFile and is meant to be very
permissive, and will return errors only when no hooks have been loaded.
Otherwise it will simply return 'nil' and the potential issues/warnings
that happen during the loading of hooks (invalid file, invalid path,
issue opening, etc).
2016-06-14 11:33:26 +02:00

591 lines
16 KiB
Go

package hook
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"reflect"
"regexp"
"strconv"
"strings"
"os"
)
// Constants used to specify the parameter source
const (
SourceHeader string = "header"
SourceQuery string = "url"
SourcePayload string = "payload"
SourceString string = "string"
SourceEntirePayload string = "entire-payload"
SourceEntireQuery string = "entire-query"
SourceEntireHeaders string = "entire-headers"
)
const (
// EnvNamespace is the prefix used for passing arguments into the command
// environment.
EnvNamespace string = "HOOK_"
)
// SignatureError describes an invalid payload signature passed to Hook.
type SignatureError struct {
Signature string
}
func (e *SignatureError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("invalid payload signature %s", e.Signature)
}
// ArgumentError describes an invalid argument passed to Hook.
type ArgumentError struct {
Argument Argument
}
func (e *ArgumentError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("couldn't retrieve argument for %+v", e.Argument)
}
// SourceError describes an invalid source passed to Hook.
type SourceError struct {
Argument Argument
}
func (e *SourceError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("invalid source for argument %+v", e.Argument)
}
// ParseError describes an error parsing user input.
type ParseError struct {
Err error
}
func (e *ParseError) Error() string {
if e == nil {
return "<nil>"
}
return e.Err.Error()
}
// CheckPayloadSignature calculates and verifies SHA1 signature of the given payload
func CheckPayloadSignature(payload []byte, secret string, signature string) (string, error) {
if strings.HasPrefix(signature, "sha1=") {
signature = signature[5:]
}
mac := hmac.New(sha1.New, []byte(secret))
_, err := mac.Write(payload)
if err != nil {
return "", err
}
expectedMAC := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedMAC)) {
return expectedMAC, &SignatureError{expectedMAC}
}
return expectedMAC, err
}
// ReplaceParameter replaces parameter value with the passed value in the passed map
// (please note you should pass pointer to the map, because we're modifying it)
// based on the passed string
func ReplaceParameter(s string, params interface{}, value interface{}) bool {
if params == nil {
return false
}
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
index, err := strconv.ParseUint(p[0], 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return false
}
return ReplaceParameter(p[1], params.([]interface{})[index], value)
}
}
return false
}
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return ReplaceParameter(p[1], pValue, value)
}
} else {
if _, ok := (*params.(*map[string]interface{}))[p[0]]; ok {
(*params.(*map[string]interface{}))[p[0]] = value
return true
}
}
return false
}
// GetParameter extracts interface{} value based on the passed string
func GetParameter(s string, params interface{}) (interface{}, bool) {
if params == nil {
return nil, false
}
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Slice {
if paramsValueSliceLength := paramsValue.Len(); paramsValueSliceLength > 0 {
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
index, err := strconv.ParseUint(p[0], 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return nil, false
}
return GetParameter(p[1], params.([]interface{})[index])
}
index, err := strconv.ParseUint(s, 10, 64)
if err != nil || paramsValueSliceLength <= int(index) {
return nil, false
}
return params.([]interface{})[index], true
}
return nil, false
}
if p := strings.SplitN(s, ".", 2); len(p) > 1 {
if paramsValue := reflect.ValueOf(params); paramsValue.Kind() == reflect.Map {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return GetParameter(p[1], pValue)
}
} else {
return nil, false
}
} else {
if pValue, ok := params.(map[string]interface{})[p[0]]; ok {
return pValue, true
}
}
return nil, false
}
// ExtractParameterAsString extracts value from interface{} as string based on the passed string
func ExtractParameterAsString(s string, params interface{}) (string, bool) {
if pValue, ok := GetParameter(s, params); ok {
return fmt.Sprintf("%v", pValue), true
}
return "", false
}
// Argument type specifies the parameter key name and the source it should
// be extracted from
type Argument struct {
Source string `json:"source,omitempty"`
Name string `json:"name,omitempty"`
EnvName string `json:"envname,omitempty"`
}
// Get Argument method returns the value for the Argument's key name
// based on the Argument's source
func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string, bool) {
var source *map[string]interface{}
switch ha.Source {
case SourceHeader:
source = headers
case SourceQuery:
source = query
case SourcePayload:
source = payload
case SourceString:
return ha.Name, true
case SourceEntirePayload:
r, err := json.Marshal(payload)
if err != nil {
return "", false
}
return string(r), true
case SourceEntireHeaders:
r, err := json.Marshal(headers)
if err != nil {
return "", false
}
return string(r), true
case SourceEntireQuery:
r, err := json.Marshal(query)
if err != nil {
return "", false
}
return string(r), true
}
if source != nil {
return ExtractParameterAsString(ha.Name, *source)
}
return "", false
}
// Header is a structure containing header name and it's value
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// ResponseHeaders is a slice of Header objects
type ResponseHeaders []Header
func (h *ResponseHeaders) String() string {
// a 'hack' to display name=value in flag usage listing
if len(*h) == 0 {
return "name=value"
}
result := make([]string, len(*h))
for idx, responseHeader := range *h {
result[idx] = fmt.Sprintf("%s=%s", responseHeader.Name, responseHeader.Value)
}
return fmt.Sprint(strings.Join(result, ", "))
}
// Set method appends new Header object from header=value notation
func (h *ResponseHeaders) Set(value string) error {
splitResult := strings.SplitN(value, "=", 2)
if len(splitResult) != 2 {
return errors.New("header flag must be in name=value format")
}
*h = append(*h, Header{Name: splitResult[0], Value: splitResult[1]})
return nil
}
// Hook type is a structure containing details for a single hook
type Hook struct {
ID string `json:"id,omitempty"`
ExecuteCommand string `json:"execute-command,omitempty"`
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
ResponseMessage string `json:"response-message,omitempty"`
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`
JSONStringParameters []Argument `json:"parse-parameters-as-json,omitempty"`
TriggerRule *Rules `json:"trigger-rule,omitempty"`
}
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
// string with the newly created object
func (h *Hook) ParseJSONParameters(headers, query, payload *map[string]interface{}) error {
for i := range h.JSONStringParameters {
if arg, ok := h.JSONStringParameters[i].Get(headers, query, payload); ok {
var newArg map[string]interface{}
decoder := json.NewDecoder(strings.NewReader(string(arg)))
decoder.UseNumber()
err := decoder.Decode(&newArg)
if err != nil {
return &ParseError{err}
}
var source *map[string]interface{}
switch h.JSONStringParameters[i].Source {
case SourceHeader:
source = headers
case SourcePayload:
source = payload
case SourceQuery:
source = query
}
if source != nil {
ReplaceParameter(h.JSONStringParameters[i].Name, source, newArg)
} else {
return &SourceError{h.JSONStringParameters[i]}
}
} else {
return &ArgumentError{h.JSONStringParameters[i]}
}
}
return nil
}
// ExtractCommandArguments creates a list of arguments, based on the
// PassArgumentsToCommand property that is ready to be used with exec.Command()
func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]interface{}) ([]string, error) {
var args = make([]string, 0)
args = append(args, h.ExecuteCommand)
for i := range h.PassArgumentsToCommand {
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]}
}
}
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 {
if h.PassEnvironmentToCommand[i].EnvName != "" {
// first try to use the EnvName if specified
args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].EnvName+"="+arg)
} else {
// then fallback on the name
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
// LoadFromFile attempts to load hooks from specified JSON file
func (h *Hooks) LoadFromFile(path string) error {
if path == "" {
return nil
}
// parse hook file for hooks
file, e := ioutil.ReadFile(path)
if e != nil {
return e
}
e = json.Unmarshal(file, h)
return e
}
// LoadFromDir attempts to load hooks from specified Dir
func (h *Hooks) LoadFromDir(path string) ([]string, error) {
warnings := []string{}
loaded := fmt.Errorf("no single hooks loaded from directory %s", path)
if _, e := os.Stat(path); os.IsNotExist(e) {
if path == "" {return []string{"path '" + path + "' is unspecified"}, nil}
return []string{"path '" + path + "' is invalid"}, loaded
}
files, e := ioutil.ReadDir(path)
if e != nil {
return []string{path + " issue while readdir"}, e
} else if len(files) == 0 {
return []string{path + " is empty"}, loaded
}
// add '/' to path if missing
if path[len(path)-1] != '/' {path += "/"}
for _, file := range files {
tmp_hooks := Hooks{}
if file.IsDir() == true {
msg, e := tmp_hooks.LoadFromDir(path + file.Name())
if len(msg) != 0 {
warnings = append(warnings, msg...)
}
if e == nil {
*h = append(*h, tmp_hooks...)
}
} else {
e := tmp_hooks.LoadFromFile(path + file.Name())
if e != nil {
warnings = append(warnings, fmt.Sprintf("%s%s (%+v)", path, file.Name(), e))
} else {
*h = append(*h, tmp_hooks...)
}
}
}
if len(*h) == 0 {return warnings, loaded}
return warnings, nil
}
// Match iterates through Hooks and returns first one that matches the given ID,
// if no hook matches the given ID, nil is returned
func (h *Hooks) Match(id string) *Hook {
for i := range *h {
if (*h)[i].ID == id {
return &(*h)[i]
}
}
return nil
}
// MatchAll iterates through Hooks and returns all of the hooks that match the
// given ID, if no hook matches the given ID, nil is returned
func (h *Hooks) MatchAll(id string) []*Hook {
var matchedHooks []*Hook
for i := range *h {
if (*h)[i].ID == id {
matchedHooks = append(matchedHooks, &(*h)[i])
}
}
if len(matchedHooks) > 0 {
return matchedHooks
}
return nil
}
// Rules is a structure that contains one of the valid rule types
type Rules struct {
And *AndRule `json:"and,omitempty"`
Or *OrRule `json:"or,omitempty"`
Not *NotRule `json:"not,omitempty"`
Match *MatchRule `json:"match,omitempty"`
}
// Evaluate finds the first rule property that is not nil and returns the value
// it evaluates to
func (r Rules) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
switch {
case r.And != nil:
return r.And.Evaluate(headers, query, payload, body)
case r.Or != nil:
return r.Or.Evaluate(headers, query, payload, body)
case r.Not != nil:
return r.Not.Evaluate(headers, query, payload, body)
case r.Match != nil:
return r.Match.Evaluate(headers, query, payload, body)
}
return false, nil
}
// AndRule will evaluate to true if and only if all of the ChildRules evaluate to true
type AndRule []Rules
// Evaluate AndRule will return true if and only if all of ChildRules evaluate to true
func (r AndRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
res := true
for _, v := range r {
rv, err := v.Evaluate(headers, query, payload, body)
if err != nil {
return false, err
}
res = res && rv
if res == false {
return res, nil
}
}
return res, nil
}
// OrRule will evaluate to true if any of the ChildRules evaluate to true
type OrRule []Rules
// Evaluate OrRule will return true if any of ChildRules evaluate to true
func (r OrRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
res := false
for _, v := range r {
rv, err := v.Evaluate(headers, query, payload, body)
if err != nil {
return false, err
}
res = res || rv
if res == true {
return res, nil
}
}
return res, nil
}
// NotRule will evaluate to true if any and only if the ChildRule evaluates to false
type NotRule Rules
// Evaluate NotRule will return true if and only if ChildRule evaluates to false
func (r NotRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
rv, err := Rules(r).Evaluate(headers, query, payload, body)
return !rv, err
}
// MatchRule will evaluate to true based on the type
type MatchRule struct {
Type string `json:"type,omitempty"`
Regex string `json:"regex,omitempty"`
Secret string `json:"secret,omitempty"`
Value string `json:"value,omitempty"`
Parameter Argument `json:"parameter,omitempty"`
}
// Constants for the MatchRule type
const (
MatchValue string = "value"
MatchRegex string = "regex"
MatchHashSHA1 string = "payload-hash-sha1"
)
// Evaluate MatchRule will return based on the type
func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, body *[]byte) (bool, error) {
if arg, ok := r.Parameter.Get(headers, query, payload); ok {
switch r.Type {
case MatchValue:
return arg == r.Value, nil
case MatchRegex:
return regexp.MatchString(r.Regex, arg)
case MatchHashSHA1:
_, err := CheckPayloadSignature(*body, r.Secret, arg)
return err == nil, err
}
}
return false, nil
}
// CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr
type CommandStatusResponse struct {
ResponseMessage string `json:"message,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}