diff --git a/go.mod b/go.mod
index f0f98758..8327eea2 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-cmp v0.6.0
github.com/google/go-github/v61 v61.0.0
+ github.com/jonboulle/clockwork v0.4.0
github.com/prometheus/client_golang v1.20.5
github.com/sethvargo/go-envconfig v1.1.0
github.com/shirou/gopsutil/v4 v4.24.11
diff --git a/go.sum b/go.sum
index 2c266f87..04cb8a07 100644
--- a/go.sum
+++ b/go.sum
@@ -235,6 +235,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
+github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
diff --git a/modules/github-events/README.md b/modules/github-events/README.md
index da2c76ba..1d4e1443 100644
--- a/modules/github-events/README.md
+++ b/modules/github-events/README.md
@@ -156,6 +156,7 @@ No requirements.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
+| [additional\_webhook\_secrets](#input\_additional\_webhook\_secrets) | Additional secrets to be used by the service.
- key: Local identifier for the secret. This will be prefixed with WEBHOOK\_SECRET\_ in the service's environment vars.
- secret: The name of the secret in Cloud Secret Manager. Format: {secretName} if the secret is in the same project. projects/{project}/secrets/{secretName} if the secret is in a different project.
- version: The version of the secret to use. Can be a number or 'latest'.
See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. |
map(object({| n/a | yes | | [deletion\_protection](#input\_deletion\_protection) | Whether to enable delete protection for the service. | `bool` | `true` | no | | [enable\_profiler](#input\_enable\_profiler) | Enable cloud profiler. | `bool` | `false` | no | | [ingress](#input\_ingress) | An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events. |
secret = string
version = string
}))
object({| n/a | yes | diff --git a/modules/github-events/cmd/trampoline/main.go b/modules/github-events/cmd/trampoline/main.go index 05aa96c0..e6d831c8 100644 --- a/modules/github-events/cmd/trampoline/main.go +++ b/modules/github-events/cmd/trampoline/main.go @@ -7,33 +7,41 @@ package main import ( "context" - "encoding/json" "fmt" "net/http" "os" "os/signal" + "strings" "syscall" "time" "github.com/chainguard-dev/clog" _ "github.com/chainguard-dev/clog/gcp/init" + "github.com/chainguard-dev/terraform-infra-common/modules/github-events/internal/trampoline" "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics" mce "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics/cloudevents" - cloudevents "github.com/cloudevents/sdk-go/v2" - "github.com/google/go-github/v61/github" "github.com/sethvargo/go-envconfig" ) var env = envconfig.MustProcess(context.Background(), &struct { - Port int `env:"PORT, default=8080"` - IngressURI string `env:"EVENT_INGRESS_URI, required"` - WebhookSecret string `env:"WEBHOOK_SECRET, required"` + Port int `env:"PORT, default=8080"` + IngressURI string `env:"EVENT_INGRESS_URI, required"` + // Note: any environment variable starting with "WEBHOOK_SECRET" will be loaded as as a webhook secret to be checked. + WebhookSecret string `env:"WEBHOOK_SECRET"` }{}) func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + // Get all secrets from the environment. + var secrets [][]byte + for _, e := range os.Environ() { + if strings.HasPrefix(e, "WEBHOOK_SECRET") { + secrets = [][]byte{[]byte(os.Getenv(e))} + } + } + go httpmetrics.ServeMetrics() defer httpmetrics.SetupTracer(ctx)() @@ -42,103 +50,10 @@ func main() { clog.FatalContextf(ctx, "failed to create cloudevents client: %v", err) } - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - log := clog.FromContext(ctx) - - defer r.Body.Close() - - // https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries - payload, err := github.ValidatePayload(r, []byte(env.WebhookSecret)) - if err != nil { - log.Errorf("failed to verify webhook: %v", err) - w.WriteHeader(http.StatusForbidden) - fmt.Fprintf(w, "failed to verify webhook: %v", err) - return - } - - // https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers - t := github.WebHookType(r) - if t == "" { - log.Errorf("missing X-GitHub-Event header") - w.WriteHeader(http.StatusBadRequest) - return - } - t = "dev.chainguard.github." + t - log = log.With("event-type", t) - - var msg struct { - Action string `json:"action"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } - if err := json.Unmarshal(payload, &msg); err != nil { - log.Warnf("failed to unmarshal payload; action and subject will be unset: %v", err) - } else { - log = log.With("action", msg.Action, "repo", msg.Repository.FullName) - } - - log.Debugf("forwarding event: %s", t) - - event := cloudevents.NewEvent() - event.SetType(t) - event.SetSource(r.Host) - event.SetSubject(msg.Repository.FullName) - event.SetExtension("action", msg.Action) - // Needs to be an extension to be a filterable attribute. - // See https://github.com/chainguard-dev/terraform-infra-common/blob/main/pkg/pubsub/cloudevent.go - if id := r.Header.Get("X-GitHub-Hook-ID"); id != "" { - event.SetExtension("github-hook-id", id) - } - if err := event.SetData(cloudevents.ApplicationJSON, eventData{ - When: time.Now(), - Headers: &eventHeaders{ - HookID: r.Header.Get("X-GitHub-Hook-ID"), - DeliveryID: r.Header.Get("X-GitHub-Delivery"), - UserAgent: r.Header.Get("User-Agent"), - Event: r.Header.Get("X-GitHub-Event"), - InstallationTargetType: r.Header.Get("X-GitHub-Installation-Target-Type"), - InstallationTargetID: r.Header.Get("X-GitHub-Installation-Target-ID"), - }, - Body: payload, - }); err != nil { - log.Errorf("failed to set data: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - const retryDelay = 10 * time.Millisecond - const maxRetry = 3 - rctx := cloudevents.ContextWithRetriesExponentialBackoff(context.WithoutCancel(ctx), retryDelay, maxRetry) - if ceresult := ceclient.Send(rctx, event); cloudevents.IsUndelivered(ceresult) || cloudevents.IsNACK(ceresult) { - log.Errorf("Failed to deliver event: %v", ceresult) - w.WriteHeader(http.StatusInternalServerError) - } - log.Debugf("event forwarded") - }) - srv := &http.Server{ Addr: fmt.Sprintf(":%d", env.Port), ReadHeaderTimeout: 10 * time.Second, + Handler: trampoline.NewServer(ceclient, secrets), } clog.FatalContextf(ctx, "ListenAndServe: %v", srv.ListenAndServe()) } - -type eventData struct { - When time.Time `json:"when,omitempty"` - // See https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers - Headers *eventHeaders `json:"headers,omitempty"` - Body json.RawMessage `json:"body,omitempty"` -} - -// Relevant headers for GitHub webhook events that we want to record. -// See https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers -type eventHeaders struct { - HookID string `json:"hook_id,omitempty"` - DeliveryID string `json:"delivery_id,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - Event string `json:"event,omitempty"` - InstallationTargetType string `json:"installation_target_type,omitempty"` - InstallationTargetID string `json:"installation_target_id,omitempty"` -} diff --git a/modules/github-events/internal/trampoline/server.go b/modules/github-events/internal/trampoline/server.go new file mode 100644 index 00000000..a1b17929 --- /dev/null +++ b/modules/github-events/internal/trampoline/server.go @@ -0,0 +1,152 @@ +package trampoline + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "time" + + "github.com/chainguard-dev/clog" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/go-github/v61/github" + "github.com/jonboulle/clockwork" +) + +type Server struct { + client cloudevents.Client + secrets [][]byte + clock clockwork.Clock +} + +func NewServer(client cloudevents.Client, secrets [][]byte) *Server { + return &Server{ + client: client, + secrets: secrets, + clock: clockwork.NewRealClock(), + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := clog.FromContext(ctx) + + // https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries + payload, err := ValidatePayload(r, s.secrets) + if err != nil { + log.Errorf("failed to verify webhook: %v", err) + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, "failed to verify webhook: %v", err) + return + } + + // https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers + t := github.WebHookType(r) + if t == "" { + log.Errorf("missing X-GitHub-Event header") + w.WriteHeader(http.StatusBadRequest) + return + } + t = "dev.chainguard.github." + t + log = log.With("event-type", t) + + var msg struct { + Action string `json:"action"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } + if err := json.Unmarshal(payload, &msg); err != nil { + log.Warnf("failed to unmarshal payload; action and subject will be unset: %v", err) + } else { + log = log.With("action", msg.Action, "repo", msg.Repository.FullName) + } + + log.Debugf("forwarding event: %s", t) + + event := cloudevents.NewEvent() + event.SetID(github.DeliveryID(r)) + event.SetType(t) + event.SetSource(r.Host) + event.SetSubject(msg.Repository.FullName) + event.SetExtension("action", msg.Action) + // Needs to be an extension to be a filterable attribute. + // See https://github.com/chainguard-dev/terraform-infra-common/blob/main/pkg/pubsub/cloudevent.go + if id := r.Header.Get("X-GitHub-Hook-ID"); id != "" { + // Cloud Event attribute spec only allows [a-z0-9] :( + event.SetExtension("githubhook", id) + } + if err := event.SetData(cloudevents.ApplicationJSON, eventData{ + When: s.clock.Now(), + Headers: &eventHeaders{ + HookID: r.Header.Get("X-GitHub-Hook-ID"), + DeliveryID: r.Header.Get("X-GitHub-Delivery"), + UserAgent: r.Header.Get("User-Agent"), + Event: r.Header.Get("X-GitHub-Event"), + InstallationTargetType: r.Header.Get("X-GitHub-Installation-Target-Type"), + InstallationTargetID: r.Header.Get("X-GitHub-Installation-Target-ID"), + }, + Body: payload, + }); err != nil { + log.Errorf("failed to set data: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + const retryDelay = 10 * time.Millisecond + const maxRetry = 3 + rctx := cloudevents.ContextWithRetriesExponentialBackoff(context.WithoutCancel(ctx), retryDelay, maxRetry) + if ceresult := s.client.Send(rctx, event); cloudevents.IsUndelivered(ceresult) || cloudevents.IsNACK(ceresult) { + log.Errorf("Failed to deliver event: %v", ceresult) + w.WriteHeader(http.StatusInternalServerError) + } + log.Debugf("event forwarded") +} + +type eventData struct { + When time.Time `json:"when"` + // See https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers + Headers *eventHeaders `json:"headers,omitempty"` + Body json.RawMessage `json:"body"` +} + +// Relevant headers for GitHub webhook events that we want to record. +// See https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers +type eventHeaders struct { + HookID string `json:"hook_id,omitempty"` + DeliveryID string `json:"delivery_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Event string `json:"event,omitempty"` + InstallationTargetType string `json:"installation_target_type,omitempty"` + InstallationTargetID string `json:"installation_target_id,omitempty"` +} + +// ValidatePayload validates the payload of a webhook request for a given set of secrets. +// If any of the secrets are valid, the payload is returned with no error. +func ValidatePayload(r *http.Request, secrets [][]byte) ([]byte, error) { + // Largely forked from github.ValidatePayload - we can't use this directly to avoid consuming the body. + signature := r.Header.Get(github.SHA256SignatureHeader) + if signature == "" { + signature = r.Header.Get(github.SHA1SignatureHeader) + } + contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + for _, secret := range secrets { + payload, err := github.ValidatePayloadFromBody(contentType, bytes.NewBuffer(body), signature, secret) + if err == nil { + return payload, nil + } + } + return nil, fmt.Errorf("failed to validate payload") +} diff --git a/modules/github-events/internal/trampoline/server_test.go b/modules/github-events/internal/trampoline/server_test.go new file mode 100644 index 00000000..3e8cb645 --- /dev/null +++ b/modules/github-events/internal/trampoline/server_test.go @@ -0,0 +1,141 @@ +package trampoline + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/cloudevents/sdk-go/v2/types" + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v61/github" + "github.com/jonboulle/clockwork" +) + +type fakeClient struct { + cloudevents.Client + + events []cloudevents.Event +} + +func (f *fakeClient) Send(_ context.Context, event cloudevents.Event) cloudevents.Result { + fmt.Println("send!", event) + f.events = append(f.events, event) + return nil +} + +func TestTrampoline(t *testing.T) { + client := &fakeClient{} + + secret := []byte("hunter2") + clock := clockwork.NewFakeClock() + impl := NewServer(client, [][]byte{ + []byte("badsecret"), // This secret should be ignored + secret, + }) + impl.clock = clock + + srv := httptest.NewServer(impl) + defer srv.Close() + + body := map[string]interface{}{ + "action": "push", + "repository": map[string]interface{}{ + "full_name": "org/repo", + }, + "foo": "bar", + } + resp, err := sendevent(t, srv.Client(), srv.URL, "push", body, secret) + if err != nil { + t.Fatalf("error sending event: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %v", resp.Status) + } + + // Generate expected event body + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("error encoding body: %v", err) + } + enc, err := json.Marshal(eventData{ + When: clock.Now(), + Headers: &eventHeaders{ + HookID: "1234", + DeliveryID: "5678", + UserAgent: t.Name(), + Event: "push", + }, + Body: json.RawMessage(b), + }) + if err != nil { + t.Fatalf("error encoding body: %v", err) + } + + want := []cloudevents.Event{{ + Context: cloudevents.EventContextV1{ + Type: "dev.chainguard.github.push", + Source: *types.ParseURIRef("localhost"), + ID: "5678", + DataContentType: cloudevents.StringOfApplicationJSON(), + Subject: github.String("org/repo"), + Extensions: map[string]interface{}{ + "action": "push", + "githubhook": "1234", + }, + }.AsV1(), + DataEncoded: enc, + }} + if diff := cmp.Diff(want, client.events); diff != "" { + t.Error(diff) + } +} + +func sendevent(t *testing.T, client *http.Client, url string, eventType string, payload interface{}, secret []byte) (*http.Response, error) { + t.Helper() + + b := new(bytes.Buffer) + if err := json.NewEncoder(b).Encode(payload); err != nil { + t.Fatalf("error encoding payload: %v", err) + } + + // Compute the signature + mac := hmac.New(sha256.New, secret) + mac.Write(b.Bytes()) + sig := fmt.Sprintf("sha256=%s", hex.EncodeToString(mac.Sum(nil))) + + r, err := http.NewRequest(http.MethodPost, url, b) + if err != nil { + return nil, err + } + r.Host = "localhost" + r.Header.Add("Content-Type", "application/json") + r.Header.Add(github.SHA256SignatureHeader, sig) + r.Header.Add(github.EventTypeHeader, eventType) + r.Header.Add("X-Github-Hook-ID", "1234") + r.Header.Add(github.DeliveryIDHeader, "5678") + r.Header.Set("User-Agent", t.Name()) + + return client.Do(r) +} + +func TestForbidden(t *testing.T) { + srv := httptest.NewServer(NewServer(&fakeClient{}, nil)) + defer srv.Close() + + // Doesn't really matter what we send, we just want to ensure we get a forbidden response + resp, err := sendevent(t, srv.Client(), srv.URL, "push", nil, nil) + if err != nil { + t.Fatalf("error sending event: %v", err) + } + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("unexpected status: %v", resp.Status) + } +} diff --git a/modules/github-events/main.tf b/modules/github-events/main.tf index 75775274..76fd8b9b 100644 --- a/modules/github-events/main.tf +++ b/modules/github-events/main.tf @@ -34,8 +34,8 @@ module "this" { deletion_protection = var.deletion_protection - squad = var.squad - require_squad = var.require_squad + squad = var.squad + require_squad = var.require_squad service_account = google_service_account.service.email containers = { "trampoline" = { @@ -44,15 +44,27 @@ module "this" { importpath = "./cmd/trampoline" } ports = [{ container_port = 8080 }] - env = [{ - name = "WEBHOOK_SECRET" - value_source = { - secret_key_ref = { - secret = module.webhook-secret.secret_id - version = "latest" + env = concat( + [{ + name = "WEBHOOK_SECRET" + value_source = { + secret_key_ref = { + secret = module.webhook-secret.secret_id + version = "latest" + } } - } - }] + }], + [for name, secret in var.additional_webhook_secrets : { + name = "WEBHOOK_SECRET_${upper(name)}" + value_source = { + secret_key_ref = { + secret = secret.secret + version = secret.version + } + } + }], + ) + regional-env = [{ name = "EVENT_INGRESS_URI" value = { for k, v in module.trampoline-emits-events : k => v.uri } diff --git a/modules/github-events/variables.tf b/modules/github-events/variables.tf index 23f96377..b9880f83 100644 --- a/modules/github-events/variables.tf +++ b/modules/github-events/variables.tf @@ -43,6 +43,22 @@ variable "secret_version_adder" { description = "The user allowed to populate new webhook secret versions." } +variable "additional_webhook_secrets" { + type = map(object({ + secret = string + version = string + })) + description = <
name = string
})