Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

github-events: Add multisecret support to trampoline webhook. #656

Merged
merged 1 commit into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -237,6 +237,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
Loading