internal/{gaby,github}: add gaby endpoint github-event (webhook listener)

Add a gaby endpoint, /github-event, which listens for
POST requests from GitHub webhooks and logs the payload
if the request is valid.

(This can be extended to take immediate action when,
for example, a new issue is posted.)

The validation logic is in the internal/github package.
The request is validated by checking the payload against
the HMAC in the request header, using a secret stored
in GCP secret manager.

Change-Id: I96f6779799be702ee8dc1b7631cce5bd3eaad3f1
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/610335
Reviewed-by: Jonathan Amsterdam <jba@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gaby/github_event.go b/internal/gaby/github_event.go
new file mode 100644
index 0000000..5976734
--- /dev/null
+++ b/internal/gaby/github_event.go
@@ -0,0 +1,31 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"golang.org/x/oscar/internal/github"
+)
+
+// handleGitHubEvent takes action when an event occurs on GitHub.
+// Currently, the function only logs that it was able to validate
+// and parse the request.
+func (g *Gaby) handleGitHubEvent(r *http.Request) error {
+	payload, err := github.ValidateWebhookRequest(r, g.secret)
+	if err != nil {
+		return fmt.Errorf("invalid request: %w", err)
+	}
+
+	var event map[string]any
+	if err := json.Unmarshal(payload, &event); err != nil {
+		return fmt.Errorf("could not unmarshal payload: %w", err)
+	}
+
+	g.slog.Info("new GitHub event", "event", event)
+	return nil
+}
diff --git a/internal/gaby/github_event_test.go b/internal/gaby/github_event_test.go
new file mode 100644
index 0000000..e9189a5
--- /dev/null
+++ b/internal/gaby/github_event_test.go
@@ -0,0 +1,28 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"log/slog"
+	"testing"
+
+	"golang.org/x/oscar/internal/github"
+)
+
+func TestHandleGitHubEvent(t *testing.T) {
+	validPayload := `{"number":1}`
+	r, db := github.ValidWebhookTestdata(t, validPayload)
+	g := &Gaby{secret: db, slog: slog.Default()}
+	if err := g.handleGitHubEvent(r); err != nil {
+		t.Fatalf("handleGitHubEvent err = %v, want nil", err)
+	}
+
+	invalidPayload := "not JSON"
+	r2, db2 := github.ValidWebhookTestdata(t, invalidPayload)
+	g2 := &Gaby{secret: db2, slog: slog.Default()}
+	if err := g2.handleGitHubEvent(r2); err == nil {
+		t.Fatal("handleGitHubEvent err = nil, want err")
+	}
+}
diff --git a/internal/gaby/main.go b/internal/gaby/main.go
index 908cac4..7100e39 100644
--- a/internal/gaby/main.go
+++ b/internal/gaby/main.go
@@ -269,7 +269,13 @@
 
 // serveHTTP serves HTTP endpoints for Gaby.
 func (g *Gaby) serveHTTP() {
-	cronCounter := g.newCounter("crons", "number of /cron requests")
+	const (
+		cronEndpoint        = "cron"
+		setLevelEndpoint    = "setlevel"
+		githubEventEndpoint = "github-event"
+	)
+	cronEndpointCounter := g.newEndpointCounter(cronEndpoint)
+	githubEventEndpointCounter := g.newEndpointCounter(githubEventEndpoint)
 
 	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		fmt.Fprintf(w, "Gaby\n")
@@ -280,7 +286,7 @@
 
 	// setlevel changes the log level dynamically.
 	// Usage: /setlevel?l=LEVEL
-	http.HandleFunc("/setlevel", func(w http.ResponseWriter, r *http.Request) {
+	http.HandleFunc("/"+setLevelEndpoint, func(w http.ResponseWriter, r *http.Request) {
 		if err := g.slogLevel.UnmarshalText([]byte(r.FormValue("l"))); err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -289,18 +295,36 @@
 		g.slog.Info("log level set", "new-level", g.slogLevel.Level())
 	})
 
-	// cron is called periodically by a Cloud Scheduler job.
-	http.HandleFunc("/cron", func(w http.ResponseWriter, r *http.Request) {
-		g.slog.Info("cron start")
-		defer g.slog.Info("cron end")
+	// cronEndpoint is called periodically by a Cloud Scheduler job.
+	http.HandleFunc("/"+cronEndpoint, func(w http.ResponseWriter, r *http.Request) {
+		g.slog.Info(cronEndpoint + " start")
+		defer g.slog.Info(cronEndpoint + " end")
 
-		g.db.Lock("gabycron")
-		defer g.db.Unlock("gabycron")
+		const cronLock = "gabycron"
+		g.db.Lock(cronLock)
+		defer g.db.Unlock(cronLock)
 
 		for _, cron := range g.crons {
 			cron(g.ctx)
 		}
-		cronCounter.Add(r.Context(), 1)
+		cronEndpointCounter.Add(r.Context(), 1)
+	})
+
+	// githubEventEndpoint is called by a GitHub webhook when a new
+	// event occurs on the golang/go repo.
+	http.HandleFunc("/"+githubEventEndpoint, func(w http.ResponseWriter, r *http.Request) {
+		g.slog.Info(githubEventEndpoint + " start")
+		defer g.slog.Info(githubEventEndpoint + " end")
+
+		const githubEventLock = "gabygithubevent"
+		g.db.Lock(githubEventLock)
+		defer g.db.Unlock(githubEventLock)
+
+		if err := g.handleGitHubEvent(r); err != nil {
+			slog.Warn(githubEventEndpoint, "err", err)
+		}
+
+		githubEventEndpointCounter.Add(r.Context(), 1)
 	})
 
 	// /search: display a form for vector similarity search.
diff --git a/internal/gaby/metrics.go b/internal/gaby/metrics.go
index bff0b1f..8bdb72c 100644
--- a/internal/gaby/metrics.go
+++ b/internal/gaby/metrics.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"fmt"
 
 	"go.opentelemetry.io/otel/attribute"
 	ometric "go.opentelemetry.io/otel/metric"
@@ -23,6 +24,14 @@
 	return c
 }
 
+// newEndpointCounter creates an integer counter instrument, intended
+// to count the number of times the given endpoint is requested.
+// It panics if the counter cannot be created.
+func (g *Gaby) newEndpointCounter(endpoint string) ometric.Int64Counter {
+	name, desc := fmt.Sprintf("%ss", endpoint), fmt.Sprintf("number of /%s requests", endpoint)
+	return g.newCounter(name, desc)
+}
+
 // registerWatcherMetric adds a metric called "watcher-latest" for the latest times of Watchers.
 // The latests map contains the functions to compute the latest times, each labeled
 // by a string which becomes the value of the "name" attribute in the metric.
diff --git a/internal/github/webhook.go b/internal/github/webhook.go
new file mode 100644
index 0000000..e1a55bd
--- /dev/null
+++ b/internal/github/webhook.go
@@ -0,0 +1,165 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"golang.org/x/oscar/internal/secret"
+)
+
+// ValidateWebhookRequest verifies that the request's payload matches
+// the HMAC tag in the header and returns the raw payload.
+//
+// It is intended to validate authenticated POST requests received
+// from GitHub webhooks.
+//
+// It expects:
+//   - a POST request with a non-empty body
+//   - a "X-Hub-Signature-256" header entry of the form
+//     "sha256=HMAC", where HMAC is a valid hex-encoded HMAC tag of the
+//     request body computed with the key in db named "github-webhook"
+//
+// The function returns an error if any of these conditions is not met.
+func ValidateWebhookRequest(r *http.Request, db secret.DB) ([]byte, error) {
+	if r.Method != http.MethodPost {
+		return nil, fmt.Errorf("unexpected HTTP method %s, want %s", r.Method, http.MethodPost)
+	}
+
+	if r.Body == nil {
+		return nil, errNoPayload
+	}
+
+	data, err := io.ReadAll(r.Body)
+	r.Body.Close()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(data) == 0 {
+		return nil, errNoPayload
+	}
+
+	key, ok := db.Get(githubWebhookSecretName)
+	if !ok {
+		return nil, errNoKey
+	}
+
+	mac, err := parseMAC(&r.Header)
+	if err != nil {
+		return nil, err
+	}
+
+	if !validMAC(data, mac, []byte(key)) {
+		return nil, errInvalidHMAC
+	}
+
+	return data, nil
+}
+
+const (
+	githubWebhookSecretName = "github-webhook"
+	xHubSignature256Name    = "X-Hub-Signature-256"
+)
+
+var (
+	errNoKey           = fmt.Errorf("no secret for %q", githubWebhookSecretName)
+	errNoPayload       = errors.New("empty payload")
+	errInvalidHMAC     = errors.New("invalid HMAC")
+	errNoHeader        = fmt.Errorf("missing %q header entry", xHubSignature256Name)
+	errMalformedHeader = fmt.Errorf("malformed %q header entry", xHubSignature256Name)
+)
+
+// parseMAC reads the value of the SHA-256 HMAC tag from the
+// X-Hub-Signature-256 header entry of h, which must be of the
+// form "sha256=HMAC", where HMAC is a hex-encoded HMAC tag.
+// It returns an error if the header entry is not present or malformed.
+func parseMAC(h *http.Header) ([]byte, error) {
+	entry := h.Get(xHubSignature256Name)
+	if entry == "" {
+		return nil, errNoHeader
+	}
+	hexMAC, ok := strings.CutPrefix(entry, "sha256=")
+	if !ok {
+		return nil, errMalformedHeader
+	}
+	return hex.DecodeString(hexMAC)
+}
+
+// computeMAC computes the SHA-256 HMAC tag for message with key.
+func computeMAC(message []byte, key []byte) []byte {
+	mac := hmac.New(sha256.New, key)
+	mac.Write(message)
+	return mac.Sum(nil)
+}
+
+// validMAC reports whether messageMAC is a valid SHA-256 HMAC tag
+// for message with key.
+func validMAC(message, messageMAC, key []byte) bool {
+	expectedMAC := computeMAC(message, key)
+	return hmac.Equal(messageMAC, expectedMAC)
+}
+
+// ValidWebhookTestdata returns an HTTP request and a secret DB
+// (inputs to ValidateWebhookRequest) that will pass validation.
+// payload is the body of the returned request.
+//
+// For testing.
+func ValidWebhookTestdata(t *testing.T, payload string) (*http.Request, secret.DB) {
+	key := "test-key"
+	signature := computeXHubSignature256(t, payload, key)
+	return newWebhookRequest(t, signature, payload), newWebhookSecretDB(t, key)
+}
+
+// computeXHubSignature256 returns the expected value of the
+// X-Hub-Signature-256 header entry in a GitHub webhook request, of the
+// form "sha256=HMAC" where HMAC is the hex-encoded SHA-256 HMAC tag of
+// the given payload created with key.
+//
+// For testing.
+func computeXHubSignature256(t *testing.T, payload, key string) string {
+	t.Helper()
+
+	h := computeMAC([]byte(payload), []byte(key))
+	return fmt.Sprintf("sha256=%s", hex.EncodeToString(h))
+}
+
+// newWebhookSecretDB returns an in-memory secret DB with a single
+// key-value pair {"github-webhook": key}.
+//
+// For testing.
+func newWebhookSecretDB(t *testing.T, key string) secret.DB {
+	t.Helper()
+
+	db := secret.Map{}
+	db.Set(githubWebhookSecretName, key)
+	return db
+}
+
+// newWebhookRequest returns an HTTP POST request of the form that would
+// be sent by a GitHub webhook, with the request body set to payload,
+// and the "X-Hub-Signature-256" header entry set to xHubSignature256.
+//
+// For testing.
+func newWebhookRequest(t *testing.T, xHubSignature256, payload string) *http.Request {
+	t.Helper()
+
+	r, err := http.NewRequest(http.MethodPost, "", strings.NewReader(payload))
+	if err != nil {
+		t.Fatal("could not create test request")
+	}
+	if xHubSignature256 != "" {
+		r.Header.Set(xHubSignature256Name, xHubSignature256)
+	}
+	return r
+}
diff --git a/internal/github/webhook_test.go b/internal/github/webhook_test.go
new file mode 100644
index 0000000..69ec51b
--- /dev/null
+++ b/internal/github/webhook_test.go
@@ -0,0 +1,123 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+
+	"golang.org/x/oscar/internal/secret"
+)
+
+func TestValidateWebhookRequest(t *testing.T) {
+	// Example test case from GitHub
+	// (https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#testing-the-webhook-payload-validation).
+	defaultHeaderEntry := "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
+	defaultPayload := "Hello, World!"
+	defaultKey := "It's a Secret to Everybody"
+
+	t.Run("success", func(t *testing.T) {
+		for _, tc := range []struct {
+			name        string
+			headerEntry string
+			payload     string
+			key         string
+		}{
+			{
+				name:        "hardcoded",
+				headerEntry: defaultHeaderEntry,
+				payload:     defaultPayload,
+				key:         defaultKey,
+			},
+			{
+				name:        "computed",
+				headerEntry: computeXHubSignature256(t, "a payload", "a key"),
+				payload:     "a payload",
+				key:         "a key",
+			},
+		} {
+			t.Run(tc.name, func(t *testing.T) {
+				r := newWebhookRequest(t, tc.headerEntry, tc.payload)
+				db := newWebhookSecretDB(t, tc.key)
+
+				got, err := ValidateWebhookRequest(r, db)
+				if err != nil {
+					t.Fatalf("ValidateWebhookRequest err = %s, want nil", err)
+				}
+				want := []byte(tc.payload)
+				if !bytes.Equal(got, want) {
+					t.Errorf("ValidateWebhookRequest = %q, want %q", got, want)
+				}
+			})
+		}
+	})
+
+	t.Run("error", func(t *testing.T) {
+		for _, tc := range []struct {
+			name        string
+			headerEntry string
+			payload     string
+			key         string
+			wantErr     error
+		}{
+			{
+				name:        "no X-Hub-Signature-256 header entry",
+				headerEntry: "",
+				payload:     defaultPayload,
+				key:         defaultKey,
+				wantErr:     errNoHeader,
+			},
+			{
+				name:        "malformed X-Hub-Signature-256 header entry",
+				headerEntry: "sha=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17",
+				payload:     defaultPayload,
+				key:         defaultKey,
+				wantErr:     errMalformedHeader,
+			},
+			{
+				name:        "wrong payload",
+				headerEntry: defaultHeaderEntry,
+				payload:     "a different payload",
+				key:         defaultKey,
+				wantErr:     errInvalidHMAC,
+			},
+			{
+				name:        "wrong key",
+				headerEntry: defaultHeaderEntry,
+				payload:     defaultPayload,
+				key:         "a different key",
+				wantErr:     errInvalidHMAC,
+			},
+			{
+				name:        "no key",
+				headerEntry: defaultHeaderEntry,
+				payload:     defaultPayload,
+				key:         "",
+				wantErr:     errNoKey,
+			},
+			{
+				name:        "no payload",
+				headerEntry: defaultHeaderEntry,
+				payload:     "",
+				key:         defaultKey,
+				wantErr:     errNoPayload,
+			},
+		} {
+			t.Run(tc.name, func(t *testing.T) {
+				r := newWebhookRequest(t, tc.headerEntry, tc.payload)
+				db := secret.Empty()
+				if tc.key != "" {
+					db = newWebhookSecretDB(t, tc.key)
+				}
+
+				_, err := ValidateWebhookRequest(r, db)
+				if !errors.Is(err, tc.wantErr) {
+					t.Errorf("ValidateWebhookRequest err = %v, want error %v", err, tc.wantErr)
+				}
+			})
+		}
+	})
+}