Skip to content

Commit

Permalink
github-events: Add multisecret support to trampoline webhook. (#656)
Browse files Browse the repository at this point in the history
Adds the ability for the trampoline to accept multiple secrets. 

This tries to retain the existing behavior of the webhook assuming at
least one secret by allowing additional secrets to be specified by a
similar `WEBHOOK_SECRET_` prefix.

Additional secrets must be defined and loaded out of band of the module.

Also adds testing for the trampoline to verify behavior.

Part of chainguard-dev/internal-dev#1513
  • Loading branch information
wlynch authored Dec 12, 2024
1 parent cb40064 commit 6af84ce
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 110 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions modules/github-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ No requirements.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_additional_webhook_secrets"></a> [additional\_webhook\_secrets](#input\_additional\_webhook\_secrets) | Additional secrets to be used by the service.<br/><br/>- key: Local identifier for the secret. This will be prefixed with WEBHOOK\_SECRET\_ in the service's environment vars.<br/>- 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.<br/>- version: The version of the secret to use. Can be a number or 'latest'.<br/><br/>See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. | <pre>map(object({<br/> secret = string<br/> version = string<br/> }))</pre> | n/a | yes |
| <a name="input_deletion_protection"></a> [deletion\_protection](#input\_deletion\_protection) | Whether to enable delete protection for the service. | `bool` | `true` | no |
| <a name="input_enable_profiler"></a> [enable\_profiler](#input\_enable\_profiler) | Enable cloud profiler. | `bool` | `false` | no |
| <a name="input_ingress"></a> [ingress](#input\_ingress) | An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events. | <pre>object({<br/> name = string<br/> })</pre> | n/a | yes |
Expand Down
115 changes: 15 additions & 100 deletions modules/github-events/cmd/trampoline/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)()

Expand All @@ -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"`
}
152 changes: 152 additions & 0 deletions modules/github-events/internal/trampoline/server.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading

0 comments on commit 6af84ce

Please sign in to comment.