From 8f1603b48e021800ba03ff83e0761d9b34ab1f7e Mon Sep 17 00:00:00 2001 From: Matt Moore Date: Mon, 20 May 2024 09:30:43 -0700 Subject: [PATCH] Add a webhook to validate trust policies (#285) This is based on Billy's PR, but I've rebased it on https://github.com/octo-sts/app/pull/284 and expanded it a bunch based on some experimentation in my dev environment. Draft until we land the base PR. Fixes: https://github.com/octo-sts/app/pull/247 Fixes: https://github.com/octo-sts/app/issues/46 Co-authored-by: wlynch --- cmd/webhook/main.go | 89 ++++++++++ go.mod | 5 + go.sum | 11 ++ iac/gclb.tf | 5 +- iac/main.tf | 18 +- iac/terraform.tfvars | 3 + iac/variables.tf | 4 + modules/app/main.tf | 2 +- modules/app/outputs.tf | 13 ++ modules/app/variables.tf | 11 +- modules/app/webhook.tf | 57 +++++++ pkg/webhook/webhook.go | 346 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 557 insertions(+), 7 deletions(-) create mode 100644 cmd/webhook/main.go create mode 100644 modules/app/outputs.tf create mode 100644 modules/app/webhook.tf create mode 100644 pkg/webhook/webhook.go diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go new file mode 100644 index 0000000..f4267bd --- /dev/null +++ b/cmd/webhook/main.go @@ -0,0 +1,89 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "time" + + kms "cloud.google.com/go/kms/apiv1" + secretmanager "cloud.google.com/go/secretmanager/apiv1" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/chainguard-dev/clog" + metrics "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics" + "github.com/kelseyhightower/envconfig" + "github.com/octo-sts/app/pkg/gcpkms" + "github.com/octo-sts/app/pkg/webhook" +) + +type envConfig struct { + Port int `envconfig:"PORT" required:"true" default:"8080"` + KMSKey string `envconfig:"KMS_KEY" required:"true"` + AppID int64 `envconfig:"GITHUB_APP_ID" required:"true"` + WebhookSecret string `envconfig:"GITHUB_WEBHOOK_SECRET" required:"true"` +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + ctx = clog.WithLogger(ctx, clog.New(slog.Default().Handler())) + + go metrics.ServeMetrics() + + // Setup tracing. + defer metrics.SetupTracer(ctx)() + + var env envConfig + if err := envconfig.Process("", &env); err != nil { + log.Panicf("failed to process env var: %s", err) + } + + kms, err := kms.NewKeyManagementClient(ctx) + if err != nil { + log.Panicf("could not create kms client: %v", err) + } + signer, err := gcpkms.New(ctx, kms, env.KMSKey) + if err != nil { + log.Panicf("error creating signer: %v", err) + } + atr, err := ghinstallation.NewAppsTransportWithOptions(http.DefaultTransport, env.AppID, ghinstallation.WithSigner(signer)) + if err != nil { + log.Panicf("error creating GitHub App transport: %v", err) + } + + webhookSecrets := [][]byte{} + secretmanager, err := secretmanager.NewClient(ctx) + if err != nil { + log.Panicf("could not create secret manager client: %v", err) + } + for _, name := range strings.Split(env.WebhookSecret, ",") { + resp, err := secretmanager.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{ + Name: name, + }) + if err != nil { + log.Panicf("error fetching webhook secret %s: %v", name, err) + } + webhookSecrets = append(webhookSecrets, resp.GetPayload().GetData()) + } + + mux := http.NewServeMux() + mux.Handle("/", &webhook.Validator{ + Transport: atr, + WebhookSecret: webhookSecrets, + }) + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", env.Port), + ReadHeaderTimeout: 10 * time.Second, + Handler: mux, + } + log.Panic(srv.ListenAndServe()) +} diff --git a/go.mod b/go.mod index 55ae89b..7e4d6c2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( chainguard.dev/go-grpc-kit v0.17.3 chainguard.dev/sdk v0.1.19 cloud.google.com/go/kms v1.15.8 + cloud.google.com/go/secretmanager v1.11.5 github.com/bradleyfalzon/ghinstallation/v2 v2.9.1-0.20240116154122-7838128b61c6 github.com/chainguard-dev/clog v1.3.1 github.com/chainguard-dev/terraform-infra-common v0.6.0 @@ -13,11 +14,14 @@ require ( github.com/coreos/go-oidc/v3 v3.10.0 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-github/v58 v58.0.0 + github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/kelseyhightower/envconfig v1.4.0 golang.org/x/oauth2 v0.19.0 google.golang.org/api v0.174.0 google.golang.org/grpc v1.63.2 + gopkg.in/yaml.v2 v2.4.0 + k8s.io/apimachinery v0.29.1 sigs.k8s.io/yaml v1.4.0 ) @@ -44,6 +48,7 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 5ca2b8e..06cb92d 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM= cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA= cloud.google.com/go/kms v1.15.8 h1:szIeDCowID8th2i8XE4uRev5PMxQFqW+JjwYxL9h6xs= cloud.google.com/go/kms v1.15.8/go.mod h1:WoUHcDjD9pluCg7pNds131awnH429QGvRM3N/4MyoVs= +cloud.google.com/go/secretmanager v1.11.5 h1:82fpF5vBBvu9XW4qj0FU2C6qVMtj1RM/XHwKXUEAfYY= +cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -109,6 +111,11 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c9 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -303,6 +310,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -310,5 +319,7 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/apimachinery v0.29.1 h1:KY4/E6km/wLBguvCZv8cKTeOwwOBqFNjwJIdMkMbbRc= +k8s.io/apimachinery v0.29.1/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/iac/gclb.tf b/iac/gclb.tf index 7cd3640..9cebfeb 100644 --- a/iac/gclb.tf +++ b/iac/gclb.tf @@ -36,7 +36,10 @@ module "serverless-gclb" { public-services = { "octo-sts.dev" = { - name = var.name + name = module.app.app.name + } + "webhook.octo-sts.dev" = { + name = module.app.webhook.name } } } diff --git a/iac/main.tf b/iac/main.tf index 58f83df..b478444 100644 --- a/iac/main.tf +++ b/iac/main.tf @@ -18,7 +18,6 @@ data "google_monitoring_notification_channel" "octo-sts-slack" { display_name = "Slack Octo STS Notification" } -// Build each of the application images from source. resource "ko_build" "this" { working_dir = "${path.module}/.." importpath = "./cmd/app" @@ -29,6 +28,16 @@ resource "cosign_sign" "this" { conflict = "REPLACE" } +resource "ko_build" "webhook" { + working_dir = "${path.module}/.." + importpath = "./cmd/webhook" +} + +resource "cosign_sign" "webhook" { + image = ko_build.webhook.image_ref + conflict = "REPLACE" +} + locals { notification_channels = [ data.google_monitoring_notification_channel.octo-sts-slack.name @@ -49,9 +58,12 @@ module "app" { } domain = "octo-sts.dev" - image = cosign_sign.this.signed_ref + images = { + app = cosign_sign.this.signed_ref + webhook = cosign_sign.webhook.signed_ref + } - github_app_id = 801323 // https://github.com/settings/apps/octosts + github_app_id = var.github_app_id github_app_key_version = 1 notification_channels = local.notification_channels } diff --git a/iac/terraform.tfvars b/iac/terraform.tfvars index b45eaf9..b3af468 100644 --- a/iac/terraform.tfvars +++ b/iac/terraform.tfvars @@ -5,3 +5,6 @@ project_id = "octo-sts" regions = [ "us-central1", ] + +// https://github.com/settings/apps/octosts +github_app_id = 801323 diff --git a/iac/variables.tf b/iac/variables.tf index 7c4e548..565fd50 100644 --- a/iac/variables.tf +++ b/iac/variables.tf @@ -12,3 +12,7 @@ variable "regions" { type = list(string) default = [] } + +variable "github_app_id" { + description = "The Github App ID for the Octo STS service." +} diff --git a/modules/app/main.tf b/modules/app/main.tf index 119f605..8e5a422 100644 --- a/modules/app/main.tf +++ b/modules/app/main.tf @@ -85,7 +85,7 @@ module "this" { service_account = google_service_account.octo-sts.email containers = { "sts" = { - image = var.image + image = var.images.app ports = [{ container_port = 8080 }] env = [ { diff --git a/modules/app/outputs.tf b/modules/app/outputs.tf new file mode 100644 index 0000000..66ef178 --- /dev/null +++ b/modules/app/outputs.tf @@ -0,0 +1,13 @@ +output "app" { + depends_on = [module.this] + value = { + name = var.name + } +} + +output "webhook" { + depends_on = [module.webhook] + value = { + name = "${var.name}-webhook" + } +} diff --git a/modules/app/variables.tf b/modules/app/variables.tf index 7e4d616..c1d7323 100644 --- a/modules/app/variables.tf +++ b/modules/app/variables.tf @@ -29,9 +29,16 @@ variable "domain" { type = string } -variable "image" { +variable "images" { description = "The Octo STS application image." - default = "chainguard/octo-sts:latest" + type = object({ + app = optional(string, "chainguard/octo-sts:latest") + webhook = optional(string, "chainguard/octo-sts-webhook:latest") + }) + default = { + app = "chainguard/octo-sts:latest" + webhook = "chainguard/octo-sts-webhook:latest" + } } variable "github_app_id" { diff --git a/modules/app/webhook.tf b/modules/app/webhook.tf new file mode 100644 index 0000000..fc69aeb --- /dev/null +++ b/modules/app/webhook.tf @@ -0,0 +1,57 @@ +// Generate a random webhook secret +resource "random_password" "webhook-secret" { + length = 64 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +module "webhook-secret" { + source = "chainguard-dev/common/infra//modules/configmap" + version = "0.6.18" + + project_id = var.project_id + name = "${var.name}-webhook-secret" + data = random_password.webhook-secret.result + + service-account = google_service_account.octo-sts.email + + notification-channels = var.notification_channels +} + +module "webhook" { + source = "chainguard-dev/common/infra//modules/regional-service" + version = "0.6.18" + + project_id = var.project_id + name = "${var.name}-webhook" + regions = var.regions + + // Only accept traffic coming from GCLB. + ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" + // This needs to egress in order to talk to Github + egress = "PRIVATE_RANGES_ONLY" + + service_account = google_service_account.octo-sts.email + containers = { + "webhook" = { + image = var.images.webhook + ports = [{ container_port = 8080 }] + env = [ + { + name = "GITHUB_APP_ID" + value = var.github_app_id + }, + { + name = "GITHUB_WEBHOOK_SECRET" + value = module.webhook-secret.secret_version_id + }, + { + name = "KMS_KEY" + value = local.kms_key + } + ] + } + } + + notification_channels = var.notification_channels +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go new file mode 100644 index 0000000..55aeef9 --- /dev/null +++ b/pkg/webhook/webhook.go @@ -0,0 +1,346 @@ +// Copyright 2024 Chainguard, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "time" + + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/chainguard-dev/clog" + "github.com/google/go-github/v58/github" + "github.com/hashicorp/go-multierror" + "github.com/octo-sts/app/pkg/octosts" + "gopkg.in/yaml.v2" + "k8s.io/apimachinery/pkg/util/sets" +) + +const ( + // See https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#delivery-headers for list of available headers + + // HeaderDelivery is the GUID of the webhook event. + HeaderDelivery = "X-GitHub-Delivery" + // HeaderEvent is the event name of the webhook. + HeaderEvent = "X-GitHub-Event" + + // zeroHash is a special SHA value indicating a non-existent commit, + // i.e. when a branch is newly created or destroyed. + zeroHash = "0000000000000000000000000000000000000000" +) + +type Validator struct { + Transport *ghinstallation.AppsTransport + // Store multiple secrets to allow for rolling updates. + // Only one needs to match for the event to be considered valid. + WebhookSecret [][]byte +} + +func (e *Validator) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log := clog.FromContext(r.Context()).With( + HeaderDelivery, r.Header.Get(HeaderDelivery), + HeaderEvent, r.Header.Get(HeaderEvent), + ) + ctx := clog.WithLogger(r.Context(), log) + + payload, err := e.validatePayload(r) + if err != nil { + log.Errorf("error validating payload: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + eventType := github.WebHookType(r) + event, err := github.ParseWebHook(eventType, payload) + if err != nil { + log.Errorf("error parsing webhook: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // For every event handler, return back an identifier that we can + // return back to the webhook in case we need to debug. This could + // be the resource that was created, an event ID, etc. + var cr *github.CheckRun + switch event := event.(type) { + case *github.PullRequestEvent: + cr, err = e.handlePullRequest(ctx, event) + case *github.PushEvent: + cr, err = e.handlePush(ctx, event) + case *github.CheckSuiteEvent: + cr, err = e.handleCheckSuite(ctx, event) + case *github.CheckRunEvent: + cr, err = e.handleCheckSuite(ctx, &fauxCheckSuite{event}) + // TODO: CheckRun retry + default: + log.Infof("unsupported event type: %s", eventType) + // Use accepted as "we got it but didn't do anything" + w.WriteHeader(http.StatusAccepted) + return + } + if err != nil { + log.Errorf("error handling event %T: %v", event, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if cr != nil { + log.Info("created CheckRun", "check_run", cr) + } + w.WriteHeader(http.StatusOK) +} + +func (e *Validator) validatePayload(r *http.Request) ([]byte, error) { + // Taken from github.ValidatePayload - we can't use this directly since the body is consumed. + 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 _, s := range e.WebhookSecret { + payload, err := github.ValidatePayloadFromBody(contentType, bytes.NewBuffer(body), signature, s) + if err == nil { + return payload, nil + } + } + return nil, errors.New("no matching secrets") +} + +func (e *Validator) handleSHA(ctx context.Context, client *github.Client, owner, repo, sha string, files []string) (*github.CheckRun, error) { + log := clog.FromContext(ctx) + + // Commit doesn't exist - nothing to do. + if sha == zeroHash { + return nil, nil + } + + err := e.validatePolicies(ctx, client, owner, repo, sha, files) + // Whether or not the commit is verified, we still create a CheckRun. + // The only difference is whether it shows up to the user as success or + // failure. + var conclusion, title string + if err == nil { + conclusion = "success" + title = "Valid trust policy." + } else { + conclusion = "failure" + title = "Invalid trust policy." + } + + opts := github.CreateCheckRunOptions{ + Name: "Trust Policy Validation", + HeadSHA: sha, + ExternalID: github.String(sha), + Status: github.String("completed"), + Conclusion: github.String(conclusion), + StartedAt: &github.Timestamp{Time: time.Now()}, + CompletedAt: &github.Timestamp{Time: time.Now()}, + Output: &github.CheckRunOutput{ + Title: github.String(title), + Summary: github.String(err.Error()), + }, + } + + cr, _, err := client.Checks.CreateCheckRun(ctx, owner, repo, opts) + if err != nil { + log.Errorf("error creating CheckRun: %v", err) + return nil, err + } + return cr, nil +} + +func (e *Validator) validatePolicies(ctx context.Context, client *github.Client, owner, repo string, sha string, files []string) error { + var merr error + for _, f := range sets.List(sets.New(files...)) { + log := clog.FromContext(ctx).With("path", f) + + resp, _, _, err := client.Repositories.GetContents(ctx, owner, repo, f, &github.RepositoryContentGetOptions{Ref: sha}) + if err != nil { + log.Infof("failed to get content for: %v", err) + merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) + continue + } + + raw, err := resp.GetContent() + if err != nil { + log.Infof("failed to read content: %v", err) + merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) + continue + } + + switch repo { + case ".github": + if err := yaml.UnmarshalStrict([]byte(raw), &octosts.OrgTrustPolicy{}); err != nil { + log.Infof("failed to parse org trust policy: %v", err) + merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) + } + + default: + if err := yaml.UnmarshalStrict([]byte(raw), &octosts.TrustPolicy{}); err != nil { + log.Infof("failed to parse trust policy: %v", err) + merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) + } + } + } + + return merr +} + +func (e *Validator) handlePush(ctx context.Context, event *github.PushEvent) (*github.CheckRun, error) { + log := clog.FromContext(ctx).With( + "github/repo", event.GetRepo().GetFullName(), + "github/installation", event.GetInstallation().GetID(), + "github/action", event.GetAction(), + "git/ref", event.GetRef(), + "git/commit", event.GetAfter(), + "github/user", event.GetSender().GetLogin(), + ) + ctx = clog.WithLogger(ctx, log) + + owner := event.GetRepo().GetOwner().GetLogin() + repo := event.GetRepo().GetName() + sha := event.GetAfter() + installationID := event.GetInstallation().GetID() + + client := github.NewClient(&http.Client{ + Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), + }) + + // Check diff + // TODO: Pagination? + resp, _, err := client.Repositories.CompareCommits(ctx, owner, repo, event.GetBefore(), sha, &github.ListOptions{}) + if err != nil { + return nil, err + } + var files []string + for _, file := range resp.Files { + if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { + files = append(files, file.GetFilename()) + } + } + if len(files) == 0 { + return nil, nil + } + + return e.handleSHA(ctx, client, owner, repo, sha, files) +} + +func (e *Validator) handlePullRequest(ctx context.Context, pr *github.PullRequestEvent) (*github.CheckRun, error) { + log := clog.FromContext(ctx).With( + "github/repo", pr.GetRepo().GetFullName(), + "github/installation", pr.GetInstallation().GetID(), + "github/action", pr.GetAction(), + "github/pull_request", pr.GetNumber(), + "git/commit", pr.GetPullRequest().GetHead().GetSHA(), + "github/user", pr.GetSender().GetLogin(), + ) + ctx = clog.WithLogger(ctx, log) + + owner := pr.GetRepo().GetOwner().GetLogin() + repo := pr.GetRepo().GetName() + sha := pr.GetPullRequest().GetHead().GetSHA() + installationID := pr.GetInstallation().GetID() + + client := github.NewClient(&http.Client{ + Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), + }) + + // Check diff + var files []string + resp, _, err := client.PullRequests.ListFiles(ctx, owner, repo, pr.GetNumber(), &github.ListOptions{}) + if err != nil { + return nil, err + } + for _, file := range resp { + if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { + files = append(files, file.GetFilename()) + } + } + if len(files) == 0 { + return nil, nil + } + + return e.handleSHA(ctx, client, owner, repo, sha, files) +} + +type checkSuite interface { + GetRepo() *github.Repository + GetInstallation() *github.Installation + GetAction() string + GetCheckSuite() *github.CheckSuite + GetSender() *github.User +} + +func (e *Validator) handleCheckSuite(ctx context.Context, cs checkSuite) (*github.CheckRun, error) { + log := clog.FromContext(ctx).With( + "github/repo", cs.GetRepo().GetFullName(), + "github/installation", cs.GetInstallation().GetID(), + "github/action", cs.GetAction(), + "github/private", cs.GetRepo().GetPrivate(), + "github/checksuite_id", cs.GetCheckSuite().GetID(), + "git/commit", cs.GetCheckSuite().GetHeadSHA(), + "github/user", cs.GetSender().GetLogin(), + ) + ctx = clog.WithLogger(ctx, log) + + owner := cs.GetRepo().GetOwner().GetLogin() + repo := cs.GetRepo().GetName() + sha := cs.GetCheckSuite().GetHeadSHA() + installationID := cs.GetInstallation().GetID() + + client := github.NewClient(&http.Client{ + Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), + }) + + resp, _, err := client.Repositories.CompareCommits(ctx, owner, repo, cs.GetCheckSuite().GetBeforeSHA(), sha, &github.ListOptions{}) + if err != nil { + return nil, err + } + var files []string + for _, file := range resp.Files { + if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { + files = append(files, file.GetFilename()) + } + } + for _, pr := range cs.GetCheckSuite().PullRequests { + resp, _, err := client.PullRequests.ListFiles(ctx, owner, repo, pr.GetNumber(), &github.ListOptions{}) + if err != nil { + return nil, err + } + for _, file := range resp { + if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { + files = append(files, file.GetFilename()) + } + } + } + if len(files) == 0 { + return nil, nil + } + + return e.handleSHA(ctx, client, owner, repo, sha, files) +} + +type fauxCheckSuite struct { + *github.CheckRunEvent +} + +var _ checkSuite = (*fauxCheckSuite)(nil) + +func (f *fauxCheckSuite) GetCheckSuite() *github.CheckSuite { + return f.GetCheckRun().GetCheckSuite() +}