diff --git a/.gitignore b/.gitignore index 5635b923..409440e3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ node_modules # Architecture specific extensions/prefixes *.[568vq] [568vq].out +.DS_Store *.cgo1.go *.cgo2.c @@ -27,4 +28,4 @@ gophish_admin.key *.exe gophish.db* -gophish \ No newline at end of file +gophish diff --git a/controllers/.DS_Store b/controllers/.DS_Store new file mode 100644 index 00000000..9d4f2c95 Binary files /dev/null and b/controllers/.DS_Store differ diff --git a/controllers/api/server.go b/controllers/api/server.go index 9f2bec65..40fd03a3 100644 --- a/controllers/api/server.go +++ b/controllers/api/server.go @@ -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 } diff --git a/controllers/api/webhook.go b/controllers/api/webhook.go new file mode 100644 index 00000000..e835777e --- /dev/null +++ b/controllers/api/webhook.go @@ -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) + } +} diff --git a/controllers/route.go b/controllers/route.go index 72cf791b..576e7dd3 100644 --- a/controllers/route.go +++ b/controllers/route.go @@ -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) { diff --git a/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql b/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql new file mode 100644 index 00000000..d13f39e8 --- /dev/null +++ b/db/db_mysql/migrations/20191104103306_0.9.0_create_webhooks.sql @@ -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 + diff --git a/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql b/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql new file mode 100644 index 00000000..d13f39e8 --- /dev/null +++ b/db/db_sqlite3/migrations/20191104103306_0.9.0_create_webhooks.sql @@ -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 + diff --git a/models/campaign.go b/models/campaign.go index c97f6a99..3ce313fb 100644 --- a/models/campaign.go +++ b/models/campaign.go @@ -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 } diff --git a/models/models.go b/models/models.go index e929af02..3384aad5 100644 --- a/models/models.go +++ b/models/models.go @@ -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" diff --git a/models/webhook.go b/models/webhook.go new file mode 100644 index 00000000..11ba1c7e --- /dev/null +++ b/models/webhook.go @@ -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 +} diff --git a/static/js/src/app/gophish.js b/static/js/src/app/gophish.js index dea47c89..8f22f5a3 100644 --- a/static/js/src/app/gophish.js +++ b/static/js/src/app/gophish.js @@ -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) diff --git a/static/js/src/app/webhooks.js b/static/js/src/app/webhooks.js new file mode 100644 index 00000000..ed7a5da4 --- /dev/null +++ b/static/js/src/app/webhooks.js @@ -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), + ` +
+ + + +
+ ` + ]).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); + }); +}); diff --git a/templates/nav.html b/templates/nav.html index 728a8f7e..d32b88e3 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -27,6 +27,9 @@
  • User ManagementAdmin
  • +
  • + WebhooksAdmin +
  • {{end}}

  • diff --git a/templates/webhooks.html b/templates/webhooks.html new file mode 100644 index 00000000..84f95867 --- /dev/null +++ b/templates/webhooks.html @@ -0,0 +1,79 @@ +{{define "body"}} +
    +

    + {{.Title}} +

    +
    +
    + +
    +   +
    + +
    +
    + + + + + + + + + + + + +
    +
    + + + + + +{{end}} {{define "scripts"}} + + +{{end}} \ No newline at end of file diff --git a/webhook/doc.go b/webhook/doc.go new file mode 100644 index 00000000..83e7bb1f --- /dev/null +++ b/webhook/doc.go @@ -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 diff --git a/webhook/webhook.go b/webhook/webhook.go new file mode 100644 index 00000000..78773a87 --- /dev/null +++ b/webhook/webhook.go @@ -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 +} diff --git a/webhook/webhook_test.go b/webhook/webhook_test.go new file mode 100644 index 00000000..79258ad2 --- /dev/null +++ b/webhook/webhook_test.go @@ -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)) +}