Add Webhook Support

Adds support for managing outgoing webhooks. Closes #1602
This commit is contained in:
Alex Maslakov 2019-12-16 02:27:21 +00:00 committed by Jordan Wright
parent 699532f256
commit 28cd7a238e
17 changed files with 758 additions and 4 deletions

1
.gitignore vendored
View file

@ -13,6 +13,7 @@ node_modules
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
.DS_Store
*.cgo1.go
*.cgo2.c

BIN
controllers/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -71,6 +71,9 @@ func (as *Server) registerRoutes() {
router.HandleFunc("/import/group", as.ImportGroup)
router.HandleFunc("/import/email", as.ImportEmail)
router.HandleFunc("/import/site", as.ImportSite)
router.HandleFunc("/webhooks/", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}/validate", mid.Use(as.ValidateWebhook, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}", mid.Use(as.Webhook, mid.RequirePermission(models.PermissionModifySystem)))
as.handler = router
}

View file

@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/webhook"
"github.com/gorilla/mux"
)
// Webhooks returns a list of webhooks, both active and disabled
func (as *Server) Webhooks(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
whs, err := models.GetWebhooks()
if err != nil {
log.Error(err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, whs, http.StatusOK)
case r.Method == "POST":
wh := models.Webhook{}
err := json.NewDecoder(r.Body).Decode(&wh)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest)
return
}
err = models.PostWebhook(&wh)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh, http.StatusCreated)
}
}
// Webhook returns details of a single webhook specified by "id" parameter
func (as *Server) Webhook(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
wh, err := models.GetWebhook(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Webhook not found"}, http.StatusNotFound)
return
}
switch {
case r.Method == "GET":
JSONResponse(w, wh, http.StatusOK)
case r.Method == "DELETE":
err = models.DeleteWebhook(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
log.Infof("Deleted webhook with id: %d", id)
JSONResponse(w, models.Response{Success: true, Message: "Webhook deleted Successfully!"}, http.StatusOK)
case r.Method == "PUT":
wh2 := models.Webhook{}
err = json.NewDecoder(r.Body).Decode(&wh2)
wh2.Id = id
err = models.PutWebhook(&wh2)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh2, http.StatusOK)
}
}
// ValidateWebhook makes an HTTP request to a specified remote url to ensure that it's valid.
func (as *Server) ValidateWebhook(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST":
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
wh, err := models.GetWebhook(id)
if err != nil {
log.Error(err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
err = webhook.Send(webhook.EndPoint{URL: wh.URL, Secret: wh.Secret}, "")
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh, http.StatusOK)
}
}

View file

@ -110,6 +110,7 @@ func (as *AdminServer) registerRoutes() {
router.HandleFunc("/sending_profiles", mid.Use(as.SendingProfiles, mid.RequireLogin))
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
// Create the API routes
api := api.NewServer(api.WithWorker(as.worker))
router.PathPrefix("/api/").Handler(api)
@ -238,6 +239,13 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "users").ExecuteTemplate(w, "base", params)
}
// Webhooks is an admin-only handler that handles webhooks
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
params.Title = "Webhooks"
getTemplate(w, "webhooks").ExecuteTemplate(w, "base", params)
}
// Login handles the authentication flow for a user. If credentials are valid,
// a session is created
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {

View file

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "webhooks" (
"id" integer primary key autoincrement,
"name" varchar(255),
"url" varchar(1000),
"secret" varchar(255),
"is_active" boolean default 0
);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View file

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "webhooks" (
"id" integer primary key autoincrement,
"name" varchar(255),
"url" varchar(1000),
"secret" varchar(255),
"is_active" boolean default 0
);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View file

@ -6,6 +6,7 @@ import (
"time"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/webhook"
"github.com/jinzhu/gorm"
"github.com/sirupsen/logrus"
)
@ -157,6 +158,21 @@ func (c *Campaign) UpdateStatus(s string) error {
func (c *Campaign) AddEvent(e *Event) error {
e.CampaignId = c.Id
e.Time = time.Now().UTC()
whs, err := GetActiveWebhooks()
if err == nil {
whEndPoints := []webhook.EndPoint{}
for _, wh := range whs {
whEndPoints = append(whEndPoints, webhook.EndPoint{
URL: wh.URL,
Secret: wh.Secret,
})
}
webhook.SendAll(whEndPoints, e)
} else {
log.Errorf("error getting active webhooks: %v", err)
}
return db.Save(e).Error
}

View file

@ -2,12 +2,12 @@ package models
import (
"crypto/rand"
"fmt"
"io"
"time"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"io/ioutil"
"time"
"bitbucket.org/liamstask/goose/lib/goose"

86
models/webhook.go Normal file
View file

@ -0,0 +1,86 @@
package models
import (
"errors"
log "github.com/gophish/gophish/logger"
)
// Webhook represents the webhook model
type Webhook struct {
Id int64 `json:"id" gorm:"column:id; primary_key:yes"`
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret"`
IsActive bool `json:"is_active"`
}
// ErrURLNotSpecified indicates there was no URL specified
var ErrURLNotSpecified = errors.New("URL can't be empty")
// ErrNameNotSpecified indicates there was no name specified
var ErrNameNotSpecified = errors.New("Name can't be empty")
// GetWebhooks returns the webhooks
func GetWebhooks() ([]Webhook, error) {
whs := []Webhook{}
err := db.Find(&whs).Error
return whs, err
}
// GetActiveWebhooks returns the active webhooks
func GetActiveWebhooks() ([]Webhook, error) {
whs := []Webhook{}
err := db.Where("is_active=?", true).Find(&whs).Error
return whs, err
}
// GetWebhook returns the webhook that the given id corresponds to.
// If no webhook is found, an error is returned.
func GetWebhook(id int64) (Webhook, error) {
wh := Webhook{}
err := db.Where("id=?", id).First(&wh).Error
return wh, err
}
// PostWebhook creates a new webhook in the database.
func PostWebhook(wh *Webhook) error {
err := wh.Validate()
if err != nil {
log.Error(err)
return err
}
err = db.Save(wh).Error
if err != nil {
log.Error(err)
}
return err
}
// PutWebhook edits an existing webhook in the database.
func PutWebhook(wh *Webhook) error {
err := wh.Validate()
if err != nil {
log.Error(err)
return err
}
err = db.Save(wh).Error
return err
}
// DeleteWebhook deletes an existing webhook in the database.
// An error is returned if a webhook with the given id isn't found.
func DeleteWebhook(id int64) error {
err := db.Where("id=?", id).Delete(&Webhook{}).Error
return err
}
func (wh *Webhook) Validate() error {
if wh.URL == "" {
return ErrURLNotSpecified
}
if wh.Name == "" {
return ErrNameNotSpecified
}
return nil
}

View file

@ -223,6 +223,28 @@ var api = {
return query("/users/" + id, "DELETE", {}, true)
}
},
webhooks: {
get: function() {
return query("/webhooks/", "GET", {}, false)
},
post: function(webhook) {
return query("/webhooks/", "POST", webhook, false)
},
},
webhookId: {
get: function(id) {
return query("/webhooks/" + id, "GET", {}, false)
},
put: function(webhook) {
return query("/webhooks/" + webhook.id, "PUT", webhook, true)
},
delete: function(id) {
return query("/webhooks/" + id, "DELETE", {}, false)
},
ping: function(id) {
return query("/webhooks/" + id + "/validate", "POST", {}, true)
},
},
// import handles all of the "import" functions in the api
import_email: function (req) {
return query("/import/email", "POST", req, false)

View file

@ -0,0 +1,182 @@
let webhooks = [];
const dismiss = () => {
$("#name").val("");
$("#url").val("");
$("#secret").val("");
$("#is_active").prop("checked", false);
$("#flashes").empty();
};
const saveWebhook = (id) => {
let wh = {
name: $("#name").val(),
url: $("#url").val(),
secret: $("#secret").val(),
is_active: $("#is_active").is(":checked"),
};
if (id != -1) {
wh.id = id;
api.webhookId.put(wh)
.success(function(data) {
dismiss();
load();
$("#modal").modal("hide");
successFlash(`Webhook "${escape(wh.name)}" has been updated successfully!`);
})
.error(function(data) {
modalError(data.responseJSON.message)
})
} else {
api.webhooks.post(wh)
.success(function(data) {
load();
dismiss();
$("#modal").modal("hide");
successFlash(`Webhook "${escape(wh.name)}" has been created successfully!`);
})
.error(function(data) {
modalError(data.responseJSON.message)
})
}
};
const load = () => {
$("#webhookTable").hide();
$("#loading").show();
api.webhooks.get()
.success((whs) => {
webhooks = whs;
$("#loading").hide()
$("#webhookTable").show()
let webhookTable = $("#webhookTable").DataTable({
destroy: true,
columnDefs: [{
orderable: false,
targets: "no-sort"
}]
});
webhookTable.clear();
$.each(webhooks, (i, webhook) => {
webhookTable.row.add([
escapeHtml(webhook.name),
escapeHtml(webhook.url),
escapeHtml(webhook.is_active),
`
<div class="pull-right">
<button class="btn btn-primary ping_button" data-webhook-id="${webhook.id}">
Ping
</button>
<button class="btn btn-primary edit_button" data-toggle="modal" data-backdrop="static" data-target="#modal" data-webhook-id="${webhook.id}">
<i class="fa fa-pencil"></i>
</button>
<button class="btn btn-danger delete_button" data-webhook-id="${webhook.id}">
<i class="fa fa-trash-o"></i>
</button>
</div>
`
]).draw()
})
})
.error(() => {
errorFlash("Error fetching webhooks")
})
};
const editWebhook = (id) => {
$("#modalSubmit").unbind("click").click(() => {
saveWebhook(id);
});
if (id !== -1) {
api.webhookId.get(id)
.success(function(wh) {
$("#name").val(wh.name);
$("#url").val(wh.url);
$("#secret").val(wh.secret);
$("#is_active").prop("checked", wh.is_active);
})
.error(function () {
errorFlash("Error fetching webhook")
});
}
};
const deleteWebhook = (id) => {
var wh = webhooks.find(x => x.id == id);
if (!wh) {
return;
}
Swal.fire({
title: "Are you sure?",
text: `This will delete the webhook '${escape(wh.name)}'`,
type: "warning",
animation: false,
showCancelButton: true,
confirmButtonText: "Delete",
confirmButtonColor: "#428bca",
reverseButtons: true,
allowOutsideClick: false,
preConfirm: function () {
return new Promise((resolve, reject) => {
api.webhookId.delete(id)
.success((msg) => {
resolve()
})
.error((data) => {
reject(data.responseJSON.message)
})
})
.catch(error => {
Swal.showValidationMessage(error)
})
}
}).then(function(result) {
if (result.value) {
Swal.fire(
"Webhook Deleted!",
`The webhook has been deleted!`,
"success"
);
}
$("button:contains('OK')").on("click", function() {
location.reload();
})
})
};
const pingUrl = (btn, whId) => {
dismiss();
btn.disabled = true;
api.webhookId.ping(whId)
.success(function(wh) {
btn.disabled = false;
successFlash(`Ping of "${escape(wh.name)}" webhook succeeded.`);
})
.error(function(data) {
btn.disabled = false;
var wh = webhooks.find(x => x.id == whId);
if (!wh) {
return
}
errorFlash(`Ping of "${escape(wh.name)}" webhook failed: "${data.responseJSON.message}"`)
});
};
$(document).ready(function() {
load();
$("#modal").on("hide.bs.modal", function() {
dismiss();
});
$("#new_button").on("click", function() {
editWebhook(-1);
});
$("#webhookTable").on("click", ".edit_button", function(e) {
editWebhook($(this).attr("data-webhook-id"));
});
$("#webhookTable").on("click", ".delete_button", function(e) {
deleteWebhook($(this).attr("data-webhook-id"));
});
$("#webhookTable").on("click", ".ping_button", function(e) {
pingUrl(e.currentTarget, e.currentTarget.dataset.webhookId);
});
});

View file

@ -27,6 +27,9 @@
<li>
<a href="/users">User Management<span class="nav-badge badge pull-right">Admin</span></a>
</li>
<li>
<a href="/webhooks">Webhooks<span class="nav-badge badge pull-right">Admin</span></a>
</li>
{{end}}
<li>
<hr>

79
templates/webhooks.html Normal file
View file

@ -0,0 +1,79 @@
{{define "body"}}
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
<h1 class="page-header">
{{.Title}}
</h1>
<div id="flashes" class="row"></div>
<div class="row">
<button type="button" class="btn btn-primary" id="new_button" data-toggle="modal" data-backdrop="static"
data-webhook-id="-1" data-target="#modal">
<i class="fa fa-plus"></i> New Webhook</button>
</div>
&nbsp;
<div id="loading">
<i class="fa fa-spinner fa-spin fa-4x"></i>
</div>
<div class="row">
<table id="webhookTable" class="table" style="display:none;">
<thead>
<tr>
<th class="col-md-2 no-sort">Title</th>
<th class="col-md-2 no-sort">Url</th>
<th class="col-md-2 no-">Is active</th>
<th class="col-md-2 no-sort"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" role="dialog" aria-labelledby="modalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="groupModalLabel">Add or Edit Webhook</h4>
</div>
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<label class="control-label" for="title">Name:</label>
<div class="form-group">
<input type="text" class="form-control" placeholder="Name" id="name" required autofocus />
</div>
<label class="control-label" for="url">URL:</label>
<div class="form-group">
<input type="text" class="form-control" placeholder="https://example.com/webhook1" id="url" required />
</div>
<label class="control-label" for="secret">Secret:</label>
<div class="form-group">
<input type="text" class="form-control" placeholder="Secret" id="secret" required />
</div>
<div class="checkbox checkbox-primary">
<input type="checkbox" id="is_active" value="true" />
<label for="is_active">Is active <i class="fa fa-question-circle"
data-toggle="tooltip" data-placement="right"
title="Data is sent only to the active webhooks"></i>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="modalSubmit">Save changes</button>
</div>
</div>
</div>
</div>
{{end}} {{define "scripts"}}
<!-- TODO replace with "min" -->
<script src="/js/src/app/webhooks.js"></script>
{{end}}

28
webhook/doc.go Normal file
View file

@ -0,0 +1,28 @@
/*
gophish - Open-Source Phishing Framework
The MIT License (MIT)
Copyright (c) 2013 Jordan Wright
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
// Package webhook contains the functionality for handling outcoming webhooks.
package webhook

105
webhook/webhook.go Normal file
View file

@ -0,0 +1,105 @@
package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
log "github.com/gophish/gophish/logger"
)
const (
// DefaultTimeoutSeconds is amount of seconds of timeout used by HTTP sender
DefaultTimeoutSeconds = 10
// MinHTTPStatusErrorCode is the lowest number of an HTTP response which indicates an error
MinHTTPStatusErrorCode = 400
// SignatureHeader is the name of an HTTP header used to which contains signature of a webhook
SignatureHeader = "X-Gophish-Signature"
// Sha256Prefix is the prefix that specifies the hashing algorithm used for signature
Sha256Prefix = "sha256"
)
// Sender defines behaviour of an entity by which webhook is sent
type Sender interface {
Send(endPoint EndPoint, data interface{}) error
}
type defaultSender struct {
client *http.Client
}
var senderInstance = &defaultSender{
client: &http.Client{
Timeout: time.Second * DefaultTimeoutSeconds,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
// EndPoint represents and end point to send a webhook to: url and secret by which payload is signed
type EndPoint struct {
URL string
Secret string
}
// Send sends data to a single EndPoint
func Send(endPoint EndPoint, data interface{}) error {
return senderInstance.Send(endPoint, data)
}
// SendAll sends data to each of the EndPoints
func SendAll(endPoints []EndPoint, data interface{}) {
for _, ept := range endPoints {
go func(ept1 EndPoint) {
senderInstance.Send(ept1, data)
}(EndPoint{URL: ept.URL, Secret: ept.Secret})
}
}
// Send contains the implementation of sending webhook to an EndPoint
func (ds defaultSender) Send(endPoint EndPoint, data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
log.Error(err)
return err
}
req, err := http.NewRequest("POST", endPoint.URL, bytes.NewBuffer(jsonData))
signat, err := sign(endPoint.Secret, jsonData)
req.Header.Set(SignatureHeader, fmt.Sprintf("%s=%s", Sha256Prefix, signat))
req.Header.Set("Content-Type", "application/json")
resp, err := ds.client.Do(req)
if err != nil {
log.Error(err)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= MinHTTPStatusErrorCode {
errMsg := fmt.Sprintf("http status of response: %s", resp.Status)
log.Error(errMsg)
return errors.New(errMsg)
}
return nil
}
func sign(secret string, data []byte) (string, error) {
hash1 := hmac.New(sha256.New, []byte(secret))
_, err := hash1.Write(data)
if err != nil {
return "", err
}
hexStr := hex.EncodeToString(hash1.Sum(nil))
return hexStr, nil
}

95
webhook/webhook_test.go Normal file
View file

@ -0,0 +1,95 @@
package webhook
import (
"testing"
"net/http"
"net/http/httptest"
"log"
"encoding/json"
"fmt"
"io/ioutil"
"github.com/stretchr/testify/suite"
"github.com/stretchr/testify/assert"
)
type WebhookSuite struct {
suite.Suite
}
type mockSender struct {
client *http.Client
}
func newMockSender() *mockSender {
ms := &mockSender {
client: &http.Client{},
}
return ms
}
func (ms mockSender) Send(endPoint EndPoint, data interface{}) error {
log.Println("[test] mocked 'Send' function")
return nil
}
func (s *WebhookSuite) TestSendMocked() {
mcSnd := newMockSender()
endp1 := EndPoint{URL: "http://example.com/a1", Secret: "s1"}
d1 := map[string]string {
"a1": "a11",
"a2": "a22",
"a3": "a33",
}
err := mcSnd.Send(endp1, d1)
s.Nil(err)
}
func (s *WebhookSuite) TestSendReal() {
expectedSign := "004b36ca3fcbc01a08b17bf5d4a7e1aa0b10e14f55f3f8bd9acac0c7e8d2635d"
secret := "secret456"
d1 := map[string]interface{} {
"key1": "val1",
"key2": "val2",
"key3": "val3",
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("[test] running the server...")
signStartIdx := len(Sha256Prefix) + 1
realSignRaw := r.Header.Get(SignatureHeader)
realSign := realSignRaw[signStartIdx:]
assert.Equal(s.T(), expectedSign, realSign)
contTypeJsonHeader := r.Header.Get("Content-Type")
assert.Equal(s.T(), contTypeJsonHeader, "application/json")
body, err := ioutil.ReadAll(r.Body)
s.Nil(err)
var d2 map[string]interface{}
err = json.Unmarshal(body, &d2)
s.Nil(err)
assert.Equal(s.T(), d1, d2)
}))
defer ts.Close()
endp1 := EndPoint{URL: ts.URL, Secret: secret}
err := Send(endp1, d1)
s.Nil(err)
}
func (s *WebhookSuite) TestSignature() {
secret := "secret123"
payload := []byte("some payload456")
expectedSign := "ab7844c1e9149f8dc976c4188a72163c005930f3c2266a163ffe434230bdf761"
realSign, err := sign(secret, payload)
s.Nil(err)
assert.Equal(s.T(), expectedSign, realSign)
}
func TestWebhookSuite(t *testing.T) {
suite.Run(t, new(WebhookSuite))
}