From adbf7b01ced0b3a2b034275773953c4aa6597be5 Mon Sep 17 00:00:00 2001 From: Mikhail Swift Date: Thu, 30 May 2024 11:32:58 -0400 Subject: [PATCH] feat: github webhook attestor --- attestation/githubwebhook/attestor.go | 186 ++++++++++++++++++++++ attestation/githubwebhook/options.go | 27 ++++ attestation/githubwebhook/webhook.go | 215 ++++++++++++++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 attestation/githubwebhook/attestor.go create mode 100644 attestation/githubwebhook/options.go create mode 100644 attestation/githubwebhook/webhook.go diff --git a/attestation/githubwebhook/attestor.go b/attestation/githubwebhook/attestor.go new file mode 100644 index 00000000..56d8a905 --- /dev/null +++ b/attestation/githubwebhook/attestor.go @@ -0,0 +1,186 @@ +package githubwebhook + +import ( + "crypto" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/in-toto/go-witness/attestation" + "github.com/in-toto/go-witness/cryptoutil" + "github.com/in-toto/go-witness/log" + "github.com/invopop/jsonschema" +) + +const ( + Name = "githubwebhook" + Type = "https://witness.dev/attestations/githubwebhook/v0.1" + RunType = attestation.PostProductRunType +) + +var ( + _ attestation.Subjecter = &Attestor{} +) + +func init() { + attestation.RegisterAttestation(Name, Type, RunType, func() attestation.Attestor { + return New() + }) +} + +type Attestor struct { + Payload map[string]interface{} `json:"payload"` + Event string `json:"event"` + + body []byte + secret []byte + receivedSig string +} + +func New(opts ...Option) attestation.Attestor { + a := &Attestor{} + for _, opt := range opts { + opt(a) + } + + return a +} + +func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { + if len(a.body) == 0 { + return fmt.Errorf("body is required") + } + + if len(a.secret) == 0 { + return fmt.Errorf("secret is required") + } + + if len(a.receivedSig) == 0 { + return fmt.Errorf("recieved signature is required") + } + + if err := validateWebhook(a.body, string(a.receivedSig), a.secret); err != nil { + return fmt.Errorf("webhook validation failed: %w", err) + } + + if err := json.Unmarshal(a.body, &a.Payload); err != nil { + return fmt.Errorf("could not unmarshal webhook body") + } + + return nil +} + +func (a *Attestor) Name() string { + return Name +} + +func (a *Attestor) RunType() attestation.RunType { + return RunType +} + +func (a *Attestor) Schema() *jsonschema.Schema { + return jsonschema.Reflect(&a) +} + +func (a *Attestor) Type() string { + return Type +} + +func (a *Attestor) Subjects() map[string]cryptoutil.DigestSet { + subjects := make(map[string]cryptoutil.DigestSet) + hashes := []cryptoutil.DigestValue{{Hash: crypto.SHA256}} + toHash := make(map[string]string) + repo, err := RepositoryFromPayload(a.Payload) + if err != nil { + log.Debugf("could not parse repository data from github webhook: %v", err) + } else { + toHash[fmt.Sprintf("reponame:%v", repo.Name)] = repo.Name + toHash[fmt.Sprintf("repourl:%v", repo.HtmlUrl)] = repo.HtmlUrl + } + + sender, err := SenderFromPayload(a.Payload) + if err != nil { + log.Debugf("could not parse sender data from github webhook: %v", err) + } else { + toHash[fmt.Sprintf("sender:%v", sender.Login)] = sender.Login + } + switch a.Event { + case EventPush: + if err := addPushSubjects(a.Payload, toHash, subjects); err != nil { + log.Debugf("could not add push event subjects: %v", err) + } + + case EventPullRequestReview: + if err := addPullRequestReviewSubjects(a.Payload, toHash, subjects); err != nil { + log.Debugf("could not add pull request review subjects: %v", err) + } + + default: + log.Debugf("unhandled github webhook event type: %v", a.Event) + } + + for name, val := range toHash { + ds, err := cryptoutil.CalculateDigestSetFromBytes([]byte(val), hashes) + if err != nil { + log.Debugf("could not calculate digest set for subject %v: %v", name, err) + } + + subjects[name] = ds + } + + return subjects +} + +func validateWebhook(body []byte, receivedSig string, secret []byte) error { + receivedSigBytes, err := hex.DecodeString(strings.TrimPrefix(receivedSig, "sha256=")) + if err != nil { + return fmt.Errorf("could not decode received signature") + } + + mac := hmac.New(sha256.New, secret) + if _, err := mac.Write(body); err != nil { + return fmt.Errorf("could not calculate hmac: %v", err) + } + + calculatedSig := mac.Sum(nil) + if !hmac.Equal(calculatedSig, receivedSigBytes) { + return fmt.Errorf("webhook signature did not match calculated signature") + } + + return nil +} + +func addPushSubjects(payload map[string]any, toHash map[string]string, subjects map[string]cryptoutil.DigestSet) error { + commits, err := CommitsFromPayload(payload) + if err != nil { + return fmt.Errorf("could not get commits from webhook payload: %w", err) + } + + for _, commit := range commits { + toHash[fmt.Sprintf("commit:%v:author:username:%v", commit.Id, commit.Author.Username)] = commit.Author.Username + toHash[fmt.Sprintf("commit:%v:author:email:%v", commit.Id, commit.Author.Username)] = commit.Author.Email + subjects[fmt.Sprintf("commit:%v", commit.Id)] = cryptoutil.DigestSet{ + cryptoutil.DigestValue{Hash: crypto.SHA1, GitOID: false}: commit.Id, + } + } + + return nil +} + +func addPullRequestReviewSubjects(payload map[string]any, toHash map[string]string, subjects map[string]cryptoutil.DigestSet) error { + pullRequest, err := PullRequestFromPayload(payload) + if err != nil { + return fmt.Errorf("could not get pull request from webhook payload: %w", err) + } + + toHash[fmt.Sprintf("pullrequest:%v", pullRequest.HtmlUrl)] = pullRequest.HtmlUrl + toHash[fmt.Sprintf("pullrequestheadref:%v", pullRequest.Head.Ref)] = pullRequest.Head.Ref + subjects[fmt.Sprintf("pullrequestheadsha:%v", pullRequest.Head.Sha)] = cryptoutil.DigestSet{ + cryptoutil.DigestValue{Hash: crypto.SHA1, GitOID: false}: pullRequest.Head.Sha, + } + + return nil +} diff --git a/attestation/githubwebhook/options.go b/attestation/githubwebhook/options.go new file mode 100644 index 00000000..0681a872 --- /dev/null +++ b/attestation/githubwebhook/options.go @@ -0,0 +1,27 @@ +package githubwebhook + +type Option func(*Attestor) + +func WithBody(body []byte) Option { + return func(a *Attestor) { + a.body = body + } +} + +func WithSecret(secret []byte) Option { + return func(a *Attestor) { + a.secret = secret + } +} + +func WithRecievedSignature(recievedSig string) Option { + return func(a *Attestor) { + a.receivedSig = recievedSig + } +} + +func WithEvent(event string) Option { + return func(a *Attestor) { + a.Event = event + } +} diff --git a/attestation/githubwebhook/webhook.go b/attestation/githubwebhook/webhook.go new file mode 100644 index 00000000..6f60e8de --- /dev/null +++ b/attestation/githubwebhook/webhook.go @@ -0,0 +1,215 @@ +package githubwebhook + +import "fmt" + +const ( + EventPush = "push" + EventPullRequestReview = "pull_request_review" +) + +// Repository contains the repository specific information found within the webhook's payload. +// Note that this is an incomplete definition of the type. +// This is contained in every webhook type that affects a repository. +type Repository struct { + HtmlUrl string `json:"html_url"` + Name string `json:"name"` +} + +func RepositoryFromPayload(payload map[string]any) (Repository, error) { + repo := Repository{} + rd, ok := payload["repository"] + if !ok { + return repo, fmt.Errorf("repository data not found in payload") + } + + repoData, ok := rd.(map[string]any) + if !ok { + return repo, fmt.Errorf("repository data in payload was unexpected type: %T", repoData) + } + + name, ok := repoData["name"].(string) + if !ok { + return repo, fmt.Errorf("repository name not in repository data") + } + + repo.Name = name + htmlUrl, ok := repoData["html_url"].(string) + if !ok { + return repo, fmt.Errorf("repository url not in repository data") + } + + repo.HtmlUrl = htmlUrl + return repo, nil +} + +// Sender contains information about the user that triggered the webhook. +// Note that this is an incomplete definition of the type. +// This is contained in every event type. +type Sender struct { + Login string `json:"login"` +} + +func SenderFromPayload(payload map[string]any) (Sender, error) { + sender := Sender{} + sd, ok := payload["sender"] + if !ok { + return sender, fmt.Errorf("sender data not found in payload") + } + + senderData, ok := sd.(map[string]any) + if !ok { + return sender, fmt.Errorf("sender data in payload was unexpected type: %T", senderData) + } + + login, ok := senderData["login"].(string) + if !ok { + return sender, fmt.Errorf("sender name not in sender data") + } + + sender.Login = login + return sender, nil +} + +// PullRequest contains information about the pull request that the webhook pertains to. +// Note that this is an incomplete definition of the type. +// This is contained in events relating to pull requests. +type PullRequest struct { + HtmlUrl string `json:"html_url"` + Head Head `json:"head"` +} + +// Head contains information about the head commit of the pull request that the webhook pertains to. +// Note that this is an incomplete definition of the type. +// This is contained in events relating to pull requests. +type Head struct { + Sha string `json:"sha"` + Ref string `json:"ref"` +} + +func PullRequestFromPayload(payload map[string]any) (PullRequest, error) { + pullRequest := PullRequest{} + sd, ok := payload["pull_request"] + if !ok { + return pullRequest, fmt.Errorf("pull request data not found in payload") + } + + pullRequestData, ok := sd.(map[string]any) + if !ok { + return pullRequest, fmt.Errorf("pull request data in payload was unexpected type: %T", pullRequestData) + } + + htmlUrl, ok := pullRequestData["html_url"].(string) + if !ok { + return pullRequest, fmt.Errorf("url not in pull request data") + } + + pullRequest.HtmlUrl = htmlUrl + + head, err := HeadFromPullRequest(pullRequestData) + if err != nil { + return pullRequest, fmt.Errorf("head not in pull request data") + } + + pullRequest.Head = head + return pullRequest, nil +} + +func HeadFromPullRequest(pullRequestData map[string]any) (Head, error) { + head := Head{} + hd, ok := pullRequestData["head"] + if !ok { + return head, fmt.Errorf("head data not found in payload") + } + + headData, ok := hd.(map[string]any) + if !ok { + return head, fmt.Errorf("head data in payload was unexpected type: %T", headData) + } + + sha, ok := headData["sha"].(string) + if !ok { + return head, fmt.Errorf("sha not in head data") + } + + head.Sha = sha + ref, ok := headData["ref"].(string) + if !ok { + return head, fmt.Errorf("ref not in head data") + } + + head.Ref = ref + return head, nil +} + +// Commit contains information about a commit in a push event. +// Note that this is an incomplete definition of the type. +type Commit struct { + Id string `json:"id"` + Author Author `json:"author"` +} + +// Author contains information about an author of a commit. +// Note that this is an incomplete definition of the type. +type Author struct { + Username string `json:"username"` + Email string `json:"email"` +} + +func CommitsFromPayload(payload map[string]any) ([]Commit, error) { + commits := []Commit{} + cd, ok := payload["commits"] + if !ok { + return nil, fmt.Errorf("commits data not in webhook payload") + } + + commitsData, ok := cd.([]any) + if !ok { + return nil, fmt.Errorf("commits data was unexpected type: %T", cd) + } + + for _, c := range commitsData { + commitData, ok := c.(map[string]any) + if !ok { + return nil, fmt.Errorf("commit data is unexpected type: %T", cd) + } + + commit := Commit{} + id, ok := commitData["id"].(string) + if !ok { + return nil, fmt.Errorf("commit id missing in webhook payload") + } + + commit.Id = id + author, err := AuthorFromCommitData(commitData) + if err != nil { + return nil, fmt.Errorf("commit author could not be parsed from payload: %v", err) + } + + commit.Author = author + commits = append(commits, commit) + } + + return commits, nil +} + +func AuthorFromCommitData(commitData map[string]any) (Author, error) { + author := Author{} + authorData, ok := commitData["author"].(map[string]any) + if !ok { + return author, fmt.Errorf("author not found in commit data") + } + + email, ok := authorData["email"].(string) + if !ok { + return author, fmt.Errorf("author email missing in commit data") + } + + author.Email = email + username, ok := authorData["username"].(string) + if !ok { + return author, fmt.Errorf("author username missing in commit data") + } + + author.Username = username + return author, nil +}