diff --git a/provider/github-app-token/github-app-token.go b/provider/github-app-token/github-app-token.go index e07cef7e..d24b8ab0 100644 --- a/provider/github-app-token/github-app-token.go +++ b/provider/github-app-token/github-app-token.go @@ -9,16 +9,37 @@ import ( "log" "net/http" "strconv" + "strings" + + "github.com/shogo82148/actions-github-app-token/provider/github-app-token/github" ) -type Handler struct{} +type githubClient interface { + CreateStatus(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) +} + +const ( + commitStatusContext = "github-app-token" + creatorLogin = "github-actions[bot]" + creatorID = 41898282 + creatorType = "Bot" +) + +type Handler struct { + github githubClient +} func NewHandler() *Handler { - return &Handler{} + return &Handler{ + github: github.NewClient(nil), + } } type requestBody struct { GitHubToken string `json:"github_token"` + Repository string `json:"repository"` + SHA string `json:"sha"` + APIURL string `json:"api_url"` } type responseBody struct { @@ -67,6 +88,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handle(ctx context.Context, req *requestBody) (*responseBody, error) { + if err := h.validateGitHubToken(ctx, req); err != nil { + return nil, err + } + + // TODO: implement me + return &responseBody{}, nil } @@ -97,3 +124,86 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) w.WriteHeader(status) w.Write(data) } + +func (h *Handler) validateGitHubToken(ctx context.Context, req *requestBody) error { + // early check of the token prefix + // ref. https://github.blog/changelog/2021-03-31-authentication-token-format-updates-are-generally-available/ + if len(req.GitHubToken) < 4 { + return &validationError{ + message: "GITHUB_TOKEN has invalid format", + } + } + switch req.GitHubToken[:4] { + case "ghp_": + // Personal Access Tokens + return &validationError{ + message: "GITHUB_TOKEN looks like Personal Access Token. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", + } + case "gho_": + // OAuth Access tokens + return &validationError{ + message: "GITHUB_TOKEN looks like OAuth Access token. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", + } + case "ghu_": + // GitHub App user-to-server tokens + return &validationError{ + message: "GITHUB_TOKEN looks like GitHub App user-to-server token. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", + } + case "ghs_": + // GitHub App server-to-server tokens + // It's OK + case "ghr_": + // GitHub App refresh tokens + return &validationError{ + message: "GITHUB_TOKEN looks like GitHub App refresh token. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", + } + default: + // Old Format Personal Access Tokens + return &validationError{ + message: "GITHUB_TOKEN looks like Personal Access Token. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", + } + } + resp, err := h.updateCommitStatus(ctx, req, &github.CreateStatusRequest{ + State: github.CommitStateSuccess, + Description: "valid github token", + Context: commitStatusContext, + }) + if err != nil { + var githubErr *github.UnexpectedStatusCodeError + if errors.As(err, &githubErr) { + if 400 <= githubErr.StatusCode && githubErr.StatusCode < 500 { + return &validationError{ + message: "Your GITHUB_TOKEN doesn't have enough permission. Write-Permission is required.", + } + } + } + return err + } + if resp.Creator.Login != creatorLogin || resp.Creator.ID != creatorID || resp.Creator.Type != creatorType { + return &validationError{ + message: fmt.Sprintf("`github-token` isn't be generated by @%s. `github-token` must be `${{ github.token }}` or `${{ secrets.GITHUB_TOKEN }}`.", creatorLogin), + } + } + return nil +} + +func splitOwnerRepo(fullname string) (owner, repo string, err error) { + idx := strings.IndexByte(fullname, '/') + if idx < 0 { + err = &validationError{ + message: fmt.Sprintf("invalid repository name: %s", fullname), + } + return + } + owner = fullname[:idx] + repo = fullname[idx+1:] + return +} + +func (h *Handler) updateCommitStatus(ctx context.Context, req *requestBody, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { + owner, repo, err := splitOwnerRepo(req.Repository) + if err != nil { + return nil, err + } + return h.github.CreateStatus(ctx, req.GitHubToken, owner, repo, req.SHA, status) +} diff --git a/provider/github-app-token/github-app-token_test.go b/provider/github-app-token/github-app-token_test.go new file mode 100644 index 00000000..a0988885 --- /dev/null +++ b/provider/github-app-token/github-app-token_test.go @@ -0,0 +1,114 @@ +package githubapptoken + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/shogo82148/actions-github-app-token/provider/github-app-token/github" +) + +type githubClientMock struct { + CreateStatusFunc func(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) +} + +func (c *githubClientMock) CreateStatus(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { + return c.CreateStatusFunc(ctx, token, owner, repo, ref, status) +} + +func TestValidateGitHubToken(t *testing.T) { + h := &Handler{ + github: &githubClientMock{ + CreateStatusFunc: func(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { + if token != "ghs_dummyGitHubToken" { + t.Errorf("unexpected GitHub Token: want %q, got %q", "ghs_dummyGitHubToken", token) + } + if owner != "fuller-inc" { + t.Errorf("unexpected owner: want %q, got %q", "fuller-inc", owner) + } + if repo != "actions-aws-assume-role" { + t.Errorf("unexpected repo: want %q, got %q", "actions-aws-assume-role", repo) + } + if ref != "e3a45c6c16c1464826b36a598ff39e6cc98c4da4" { + t.Errorf("unexpected ref: want %q, got %q", "e3a45c6c16c1464826b36a598ff39e6cc98c4da4", ref) + } + if status.State != github.CommitStateSuccess { + t.Errorf("unexpected commit status state: want %s, got %s", github.CommitStateSuccess, status.State) + } + if status.Context != commitStatusContext { + t.Errorf("unexpected commit status context: want %q, got %q", commitStatusContext, status.Context) + } + return &github.CreateStatusResponse{ + Creator: &github.CreateStatusResponseCreator{ + Login: creatorLogin, + ID: creatorID, + Type: creatorType, + }, + }, nil + }, + }, + } + err := h.validateGitHubToken(context.Background(), &requestBody{ + GitHubToken: "ghs_dummyGitHubToken", + Repository: "fuller-inc/actions-aws-assume-role", + SHA: "e3a45c6c16c1464826b36a598ff39e6cc98c4da4", + }) + if err != nil { + t.Error(err) + } +} + +func TestValidateGitHubToken_PermissionError(t *testing.T) { + h := &Handler{ + github: &githubClientMock{ + CreateStatusFunc: func(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { + return nil, &github.UnexpectedStatusCodeError{ + StatusCode: http.StatusBadRequest, + } + }, + }, + } + err := h.validateGitHubToken(context.Background(), &requestBody{ + GitHubToken: "ghs_dummyGitHubToken", + Repository: "fuller-inc/actions-aws-assume-role", + SHA: "e3a45c6c16c1464826b36a598ff39e6cc98c4da4", + }) + if err == nil { + t.Error("want error, but not") + } + + var validate *validationError + if !errors.As(err, &validate) { + t.Errorf("want validation error, got %T", err) + } +} + +func TestValidateGitHubToken_InvalidCreator(t *testing.T) { + h := &Handler{ + github: &githubClientMock{ + CreateStatusFunc: func(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { + return &github.CreateStatusResponse{ + Creator: &github.CreateStatusResponseCreator{ + Login: "shogo82148", + ID: 1157344, + Type: "User", + }, + }, nil + }, + }, + } + err := h.validateGitHubToken(context.Background(), &requestBody{ + GitHubToken: "ghs_dummyGitHubToken", + Repository: "fuller-inc/actions-aws-assume-role", + SHA: "e3a45c6c16c1464826b36a598ff39e6cc98c4da4", + }) + if err == nil { + t.Error("want error, but not") + } + + var validate *validationError + if !errors.As(err, &validate) { + t.Errorf("want validation error, got %T", err) + } +}