Add webhook signature validation for SonarQube
Signed-off-by: Steven Kriegler <sk.bunsenbrenner@gmail.com>
This commit is contained in:
parent
34e2783cb1
commit
e5eaa0a593
41
internal/api/request_validation.go
Normal file
41
internal/api/request_validation.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func isValidWebhook(message []byte, key string, signature string, component string) (bool, error) {
|
||||
log.Printf("'%s'", signature)
|
||||
|
||||
if key == "" && signature == "" {
|
||||
// No webhook token configured and no signature header received. Skipping request validation.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if key == "" && signature != "" {
|
||||
return false, fmt.Errorf("Signature header received but no %s webhook secret configured. Request rejected due to possible configuration mismatch.", component)
|
||||
}
|
||||
|
||||
if key != "" && signature == "" {
|
||||
return false, fmt.Errorf("%s webhook secret configured but no signature header received. Request rejected due to possible configuration mismatch.", component)
|
||||
}
|
||||
|
||||
decodedSignature, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Error decoding signature for %s webhook.", component)
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write(message)
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
if !hmac.Equal(decodedSignature, sum) {
|
||||
return false, fmt.Errorf("Signature header does not match the received %s webhook content. Request rejected.", component)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
46
internal/api/request_validation_test.go
Normal file
46
internal/api/request_validation_test.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func getRequestData() []byte {
|
||||
return []byte(`{"serverUrl":"https://example.com","status":"SUCCESS","analysedAt":"2022-05-15T16:45:31+0000","revision":"378080777919s07657a07f7a3e2d05dc75f64edd","changedAt":"2022-05-15T16:41:39+0000","project":{"key":"gitea-sonarqube-bot","name":"Gitea SonarQube Bot","url":"https://example.com/dashboard?id=gitea-sonarqube-bot"},"branch":{"name":"PR-1822","type":"PULL_REQUEST","isMain":false,"url":"https://example.com/dashboard?id=gitea-sonarqube-bot&pullRequest=PR-1822"},"qualityGate":{"name":"GiteaSonarQubeBot","status":"OK","conditions":[{"metric":"new_reliability_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_security_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_maintainability_rating","operator":"GREATER_THAN","value":"1","status":"OK","errorThreshold":"1"},{"metric":"new_security_hotspots_reviewed","operator":"LESS_THAN","status":"OK","errorThreshold":"100"}]},"properties":{"sonar.analysis.sqbot":"378080777919s07657a07f7a3e2d05dc75f64edd"}}`)
|
||||
}
|
||||
|
||||
func TestIsValidWebhookSuccess(t *testing.T) {
|
||||
actual, _ := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467", "test-component")
|
||||
assert.True(t, actual, "Expected successful webhook signature validation")
|
||||
}
|
||||
|
||||
func TestIsValidWebhookNothingConfiguredOrProvidedSuccess(t *testing.T) {
|
||||
actual, _ := isValidWebhook(getRequestData(), "", "", "test-component")
|
||||
assert.True(t, actual, "Webhook signature validation not skipped")
|
||||
}
|
||||
|
||||
func TestIsValidWebhookSignatureDecodingFailure(t *testing.T) {
|
||||
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "invalid-signature", "test-component")
|
||||
assert.False(t, actual)
|
||||
assert.EqualError(t, err, "Error decoding signature for test-component webhook.", "Undetected signature encoding error")
|
||||
}
|
||||
|
||||
func TestIsValidWebhookSignatureMismatchFailure(t *testing.T) {
|
||||
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "fde6a666b7a1a46c27efb1961c17b46b6cf7aa13db5560e5ac95e801a18a92f3", "test-component")
|
||||
assert.False(t, actual)
|
||||
assert.EqualError(t, err, "Signature header does not match the received test-component webhook content. Request rejected.", "Undetected signature mismatch")
|
||||
// assert.EqualError(t, err, "Signature header received but no test-component webhook secret configured. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (1)")
|
||||
}
|
||||
|
||||
func TestIsValidWebhookEmptySecretConfigurationFailure(t *testing.T) {
|
||||
actual, err := isValidWebhook(getRequestData(), "", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467", "test-component")
|
||||
assert.False(t, actual)
|
||||
assert.EqualError(t, err, "Signature header received but no test-component webhook secret configured. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (1)")
|
||||
}
|
||||
|
||||
func TestIsValidWebhookEmptySignatureConfigurationFailure(t *testing.T) {
|
||||
actual, err := isValidWebhook(getRequestData(), "sonarqube-test-webhook-secret", "", "test-component")
|
||||
assert.False(t, actual)
|
||||
assert.EqualError(t, err, "test-component webhook secret configured but no signature header received. Request rejected due to possible configuration mismatch.", "Undetected configuration mismatch (2)")
|
||||
}
|
|
@ -87,6 +87,14 @@ func (h *SonarQubeWebhookHandler) Handle(rw http.ResponseWriter, r *http.Request
|
|||
return
|
||||
}
|
||||
|
||||
ok, err := isValidWebhook(raw, settings.SonarQube.Webhook.Secret, r.Header.Get("X-Sonar-Webhook-HMAC-SHA256"), "SonarQube")
|
||||
if !ok {
|
||||
log.Print(err.Error())
|
||||
rw.WriteHeader(http.StatusPreconditionFailed)
|
||||
io.WriteString(rw, fmt.Sprint(`{"message": "Webhook validation failed. Request rejected."}`))
|
||||
return
|
||||
}
|
||||
|
||||
w, ok := webhook.New(raw)
|
||||
if !ok {
|
||||
rw.WriteHeader(http.StatusUnprocessableEntity)
|
||||
|
|
|
@ -83,6 +83,11 @@ func withValidRequestData(t *testing.T, mockPreparation func(*HandlerPartialMock
|
|||
}
|
||||
|
||||
func TestHandleSonarQubeWebhookProjectMapped(t *testing.T) {
|
||||
settings.SonarQube = settings.SonarQubeConfig{
|
||||
Webhook: &settings.Webhook{
|
||||
Secret: "",
|
||||
},
|
||||
}
|
||||
settings.Projects = []settings.Project{
|
||||
{
|
||||
SonarQube: struct{ Key string }{
|
||||
|
@ -128,7 +133,33 @@ func TestHandleSonarQubeWebhookInvalidJSONBody(t *testing.T) {
|
|||
assert.Equal(t, `{"message": "Error parsing POST body."}`, rr.Body.String())
|
||||
}
|
||||
|
||||
func TestHandleSonarQubeWebhookInvalidWebhookSignature(t *testing.T) {
|
||||
settings.SonarQube = settings.SonarQubeConfig{
|
||||
Webhook: &settings.Webhook{
|
||||
Secret: "sonarqube-test-webhook-secret",
|
||||
},
|
||||
}
|
||||
settings.Projects = []settings.Project{
|
||||
{
|
||||
SonarQube: struct{ Key string }{
|
||||
Key: "pr-bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rr, handler, _ := withValidRequestData(t, defaultMockPreparation, []byte(`{ "serverUrl": "https://example.com/sonarqube", "taskId": "AXouyxDpizdp4B1K", "status": "SUCCESS", "analysedAt": "2021-05-21T12:12:07+0000", "revision": "f84442009c09b1adc278b6aa80a3853419f54007", "changedAt": "2021-05-21T12:12:07+0000", "project": { "key": "pr-bot", "name": "PR Bot", "url": "https://example.com/sonarqube/dashboard?id=pr-bot" }, "branch": { "name": "PR-1337", "type": "PULL_REQUEST", "isMain": false, "url": "https://example.com/sonarqube/dashboard?id=pr-bot&pullRequest=PR-1337" }, "qualityGate": { "name": "PR Bot", "status": "OK", "conditions": [ { "metric": "new_reliability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_maintainability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_hotspots_reviewed", "operator": "LESS_THAN", "status": "NO_VALUE", "errorThreshold": "100" } ] }, "properties": {} }`))
|
||||
req.Header.Set("X-Sonar-Webhook-HMAC-SHA256", "647f2395d30b1b7efcb58d9338be5b69c2addb54faf6bde6314a57ea28f45467")
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rr.Code)
|
||||
assert.Equal(t, `{"message": "Webhook validation failed. Request rejected."}`, rr.Body.String())
|
||||
}
|
||||
|
||||
func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
|
||||
settings.SonarQube = settings.SonarQubeConfig{
|
||||
Webhook: &settings.Webhook{
|
||||
Secret: "",
|
||||
},
|
||||
}
|
||||
settings.Projects = []settings.Project{
|
||||
{
|
||||
SonarQube: struct{ Key string }{
|
||||
|
@ -146,6 +177,11 @@ func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestHandleSonarQubeWebhookForBranch(t *testing.T) {
|
||||
settings.SonarQube = settings.SonarQubeConfig{
|
||||
Webhook: &settings.Webhook{
|
||||
Secret: "",
|
||||
},
|
||||
}
|
||||
settings.Projects = []settings.Project{
|
||||
{
|
||||
SonarQube: struct{ Key string }{
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
var (
|
||||
Gitea giteaConfig
|
||||
SonarQube sonarQubeConfig
|
||||
SonarQube SonarQubeConfig
|
||||
Projects []Project
|
||||
)
|
||||
|
||||
|
@ -67,7 +67,7 @@ func Load(configPath string) {
|
|||
Token: NewToken(r.GetString, "gitea", errCallback),
|
||||
Webhook: NewWebhook(r.GetString, "gitea", errCallback),
|
||||
}
|
||||
SonarQube = sonarQubeConfig{
|
||||
SonarQube = SonarQubeConfig{
|
||||
Url: r.GetString("sonarqube.url"),
|
||||
Token: NewToken(r.GetString, "sonarqube", errCallback),
|
||||
Webhook: NewWebhook(r.GetString, "sonarqube", errCallback),
|
||||
|
|
|
@ -97,7 +97,7 @@ func TestLoadSonarQubeStructure(t *testing.T) {
|
|||
WriteConfigFile(t, defaultConfig)
|
||||
Load(os.TempDir())
|
||||
|
||||
expected := sonarQubeConfig{
|
||||
expected := SonarQubeConfig{
|
||||
Url: "https://example.com/sonarqube",
|
||||
Token: &Token{
|
||||
Value: "a09eb5785b25bb2cbacf48808a677a0709f02d8e",
|
||||
|
@ -131,7 +131,7 @@ projects:
|
|||
`))
|
||||
Load(os.TempDir())
|
||||
|
||||
expected := sonarQubeConfig{
|
||||
expected := SonarQubeConfig{
|
||||
Url: "https://example.com/sonarqube",
|
||||
Token: &Token{
|
||||
Value: "fake-sonarqube-token",
|
||||
|
@ -155,7 +155,7 @@ func TestLoadSonarQubeStructureInjectedEnvs(t *testing.T) {
|
|||
WriteConfigFile(t, defaultConfig)
|
||||
Load(os.TempDir())
|
||||
|
||||
expected := sonarQubeConfig{
|
||||
expected := SonarQubeConfig{
|
||||
Url: "https://example.com/sonarqube",
|
||||
Token: &Token{
|
||||
Value: "injected-token",
|
||||
|
@ -219,7 +219,7 @@ projects:
|
|||
},
|
||||
}
|
||||
|
||||
expectedSonarQube := sonarQubeConfig{
|
||||
expectedSonarQube := SonarQubeConfig{
|
||||
Url: "https://example.com/sonarqube",
|
||||
Token: &Token{
|
||||
Value: "a09eb5785b25bb2cbacf48808a677a0709f02d8e",
|
||||
|
|
|
@ -2,14 +2,14 @@ package settings
|
|||
|
||||
import "strings"
|
||||
|
||||
type sonarQubeConfig struct {
|
||||
type SonarQubeConfig struct {
|
||||
Url string
|
||||
Token *Token
|
||||
Webhook *Webhook
|
||||
AdditionalMetrics []string
|
||||
}
|
||||
|
||||
func (c *sonarQubeConfig) GetMetricsList() string {
|
||||
func (c *SonarQubeConfig) GetMetricsList() string {
|
||||
metrics := []string{
|
||||
"bugs",
|
||||
"vulnerabilities",
|
||||
|
|
Loading…
Reference in a new issue