Gitlab support (#2265)

* updated readme to reflect gitlab usage

* bugfixes after a good deal of testing

* removed unnecessary files from branch

* cleaning up my mess

* requested changes + unit tests

* style fixes

* updated readme to reflect gitlab usage

* bugfixes after a good deal of testing

* removed unnecessary files from branch

* cleaning up my mess

* requested changes + unit tests

* style fixes

* merge main into gitlab_support

* check-linter fixes

Signed-off-by: Nathaniel Wert <N8.Wert.B@gmail.com>
Co-authored-by: nathaniel.wert <nathaniel.wert@kudelskisecurity.com>
This commit is contained in:
Nathaniel Wert 2022-09-21 16:20:20 -04:00 committed by GitHub
parent a6983edf6e
commit 0f87094997
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1982 additions and 10 deletions

View File

@ -0,0 +1,196 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type branchesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
defaultBranchRef *clients.BranchRef
}
func (handler *branchesHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
// nolint: nestif
func (handler *branchesHandler) setup() error {
handler.once.Do(func() {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
handler.errSetup = fmt.Errorf("%w: branches only supported for HEAD queries", clients.ErrUnsupportedFeature)
return
}
proj, _, err := handler.glClient.Projects.GetProject(handler.repourl.projectID, &gitlab.GetProjectOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("requirest for project failed with error %w", err)
return
}
branch, _, err := handler.glClient.Branches.GetBranch(handler.repourl.projectID, proj.DefaultBranch)
if err != nil {
handler.errSetup = fmt.Errorf("request for default branch failed with error %w", err)
return
}
if branch.Protected {
protectedBranch, resp, err := handler.glClient.ProtectedBranches.GetProtectedBranch(
handler.repourl.projectID, branch.Name)
if err != nil && resp.StatusCode != 403 {
handler.errSetup = fmt.Errorf("request for protected branch failed with error %w", err)
return
} else if resp.StatusCode == 403 {
handler.errSetup = fmt.Errorf("incorrect permissions to fully check branch protection %w", err)
return
}
projectStatusChecks, resp, err := handler.glClient.ExternalStatusChecks.ListProjectStatusChecks(
handler.repourl.projectID, &gitlab.ListOptions{})
if err != nil && resp.StatusCode != 404 {
handler.errSetup = fmt.Errorf("request for external status checks failed with error %w", err)
return
}
projectApprovalRule, resp, err := handler.glClient.Projects.GetApprovalConfiguration(handler.repourl.projectID)
if err != nil && resp.StatusCode != 404 {
handler.errSetup = fmt.Errorf("request for project approval rule failed with %w", err)
return
}
handler.defaultBranchRef = makeBranchRefFrom(branch, protectedBranch,
projectStatusChecks, projectApprovalRule)
} else {
handler.defaultBranchRef = &clients.BranchRef{
Name: &branch.Name,
Protected: &branch.Protected,
}
}
handler.errSetup = nil
})
return handler.errSetup
}
func (handler *branchesHandler) getDefaultBranch() (*clients.BranchRef, error) {
err := handler.setup()
if err != nil {
return nil, fmt.Errorf("error during branchesHandler.setup: %w", err)
}
return handler.defaultBranchRef, nil
}
func (handler *branchesHandler) getBranch(branch string) (*clients.BranchRef, error) {
bran, _, err := handler.glClient.Branches.GetBranch(handler.repourl.projectID, branch)
if err != nil {
return nil, fmt.Errorf("error getting branch in branchsHandler.getBranch: %w", err)
}
if bran.Protected {
protectedBranch, _, err := handler.glClient.ProtectedBranches.GetProtectedBranch(handler.repourl.projectID, bran.Name)
if err != nil {
return nil, fmt.Errorf("request for protected branch failed with error %w", err)
}
projectStatusChecks, resp, err := handler.glClient.ExternalStatusChecks.ListProjectStatusChecks(
handler.repourl.projectID, &gitlab.ListOptions{})
if err != nil && resp.StatusCode != 404 {
return nil, fmt.Errorf("request for external status checks failed with error %w", err)
}
projectApprovalRule, resp, err := handler.glClient.Projects.GetApprovalConfiguration(handler.repourl.projectID)
if err != nil && resp.StatusCode != 404 {
return nil, fmt.Errorf("request for project approval rule failed with %w", err)
}
return makeBranchRefFrom(bran, protectedBranch, projectStatusChecks, projectApprovalRule), nil
} else {
ret := &clients.BranchRef{
Name: &bran.Name,
Protected: &bran.Protected,
}
return ret, nil
}
}
func makeContextsFromResp(checks []*gitlab.ProjectStatusCheck) []string {
ret := make([]string, len(checks))
for i, statusCheck := range checks {
ret[i] = statusCheck.Name
}
return ret
}
func makeBranchRefFrom(branch *gitlab.Branch, protectedBranch *gitlab.ProtectedBranch,
projectStatusChecks []*gitlab.ProjectStatusCheck,
projectApprovalRule *gitlab.ProjectApprovals,
) *clients.BranchRef {
requiresStatusChecks := newFalse()
if len(projectStatusChecks) > 0 {
requiresStatusChecks = newTrue()
}
statusChecksRule := clients.StatusChecksRule{
UpToDateBeforeMerge: newTrue(),
RequiresStatusChecks: requiresStatusChecks,
Contexts: makeContextsFromResp(projectStatusChecks),
}
pullRequestReviewRule := clients.PullRequestReviewRule{
DismissStaleReviews: newTrue(),
RequireCodeOwnerReviews: &protectedBranch.CodeOwnerApprovalRequired,
}
if projectApprovalRule != nil {
requiredApprovalNum := int32(projectApprovalRule.ApprovalsBeforeMerge)
pullRequestReviewRule.RequiredApprovingReviewCount = &requiredApprovalNum
}
ret := &clients.BranchRef{
Name: &branch.Name,
Protected: &branch.Protected,
BranchProtectionRule: clients.BranchProtectionRule{
RequiredPullRequestReviews: pullRequestReviewRule,
AllowDeletions: newFalse(),
AllowForcePushes: &protectedBranch.AllowForcePush,
EnforceAdmins: newTrue(),
CheckRules: statusChecksRule,
},
}
return ret
}
func newTrue() *bool {
b := true
return &b
}
func newFalse() *bool {
b := false
return &b
}

View File

@ -0,0 +1,59 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type checkrunsHandler struct {
glClient *gitlab.Client
repourl *repoURL
}
func (handler *checkrunsHandler) init(repourl *repoURL) {
handler.repourl = repourl
}
func (handler *checkrunsHandler) listCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
pipelines, _, err := handler.glClient.Pipelines.ListProjectPipelines(
handler.repourl.projectID, &gitlab.ListProjectPipelinesOptions{})
if err != nil {
return nil, fmt.Errorf("request for pipelines returned error: %w", err)
}
return checkRunsFrom(pipelines, ref), nil
}
// Conclusion does not exist in the pipelines for gitlab so I have a placeholder "" as the value.
func checkRunsFrom(data []*gitlab.PipelineInfo, ref string) []clients.CheckRun {
var checkRuns []clients.CheckRun
for _, pipelineInfo := range data {
if strings.EqualFold(pipelineInfo.Ref, ref) {
checkRuns = append(checkRuns, clients.CheckRun{
Status: pipelineInfo.Status,
Conclusion: "",
URL: pipelineInfo.WebURL,
App: clients.CheckRunApp{Slug: pipelineInfo.Source},
})
}
}
return checkRuns
}

View File

@ -0,0 +1,261 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package gitlabrepo implements clients.RepoClient for GitLab.
package gitlabrepo
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
sce "github.com/ossf/scorecard/v4/errors"
)
var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type repoURL")
)
type Client struct {
repourl *repoURL
repo *gitlab.Project
glClient *gitlab.Client
contributors *contributorsHandler
branches *branchesHandler
releases *releasesHandler
workflows *workflowsHandler
checkruns *checkrunsHandler
commits *commitsHandler
issues *issuesHandler
project *projectHandler
statuses *statusesHandler
search *searchHandler
searchCommits *searchCommitsHandler
webhook *webhookHandler
languages *languagesHandler
ctx context.Context
// tarball tarballHandler
}
// InitRepo sets up the GitLab project in local storage for improving performance and GitLab token usage efficiency.
func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string) error {
glRepo, ok := inputRepo.(*repoURL)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}
// Sanity check.
repo, _, err := client.glClient.Projects.GetProject(glRepo.projectID, &gitlab.GetProjectOptions{})
if err != nil {
return sce.WithMessage(sce.ErrRepoUnreachable, err.Error())
}
client.repo = repo
client.repourl = &repoURL{
hostname: inputRepo.URI(),
projectID: fmt.Sprint(repo.ID),
defaultBranch: repo.DefaultBranch,
commitSHA: commitSHA,
}
if repo.Owner != nil {
client.repourl.owner = repo.Owner.Name
}
// Init contributorsHandler
client.contributors.init(client.repourl)
// Init commitsHandler
client.commits.init(client.repourl)
// Init branchesHandler
client.branches.init(client.repourl)
// Init releasesHandler
client.releases.init(client.repourl)
// Init issuesHandler
client.issues.init(client.repourl)
// Init projectHandler
client.project.init(client.repourl)
// Init workflowsHandler
client.workflows.init(client.repourl)
// Init checkrunsHandler
client.checkruns.init(client.repourl)
// Init statusesHandler
client.statuses.init(client.repourl)
// Init searchHandler
client.search.init(client.repourl)
// Init searchCommitsHandler
client.searchCommits.init(client.repourl)
// Init webhookHandler
client.webhook.init(client.repourl)
// Init languagesHandler
client.languages.init(client.repourl)
// Init tarballHandler.
// client.tarball.init(client.ctx, client.repourl, client.repo, commitSHA)
return nil
}
func (client *Client) URI() string {
return fmt.Sprintf("%s/%s/%s", client.repourl.hostname, client.repourl.owner, client.repourl.projectID)
}
func (client *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
return nil, nil
}
func (client *Client) GetFileContent(filename string) ([]byte, error) {
return nil, nil
}
func (client *Client) ListCommits() ([]clients.Commit, error) {
return client.commits.listCommits()
}
func (client *Client) ListIssues() ([]clients.Issue, error) {
return client.issues.listIssues()
}
func (client *Client) ListReleases() ([]clients.Release, error) {
return client.releases.getReleases()
}
func (client *Client) ListContributors() ([]clients.User, error) {
return client.contributors.getContributors()
}
func (client *Client) IsArchived() (bool, error) {
return client.project.isArchived()
}
func (client *Client) GetDefaultBranch() (*clients.BranchRef, error) {
return client.branches.getDefaultBranch()
}
func (client *Client) GetDefaultBranchName() (string, error) {
return client.repourl.defaultBranch, nil
}
func (client *Client) GetBranch(branch string) (*clients.BranchRef, error) {
return client.branches.getBranch(branch)
}
func (client *Client) GetCreatedAt() (time.Time, error) {
return client.project.getCreatedAt()
}
func (client *Client) ListWebhooks() ([]clients.Webhook, error) {
return client.webhook.listWebhooks()
}
func (client *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return client.workflows.listSuccessfulWorkflowRuns(filename)
}
func (client *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return client.checkruns.listCheckRunsForRef(ref)
}
func (client *Client) ListStatuses(ref string) ([]clients.Status, error) {
return client.statuses.listStatuses(ref)
}
func (client *Client) ListProgrammingLanguages() ([]clients.Language, error) {
return client.languages.listProgrammingLanguages()
}
func (client *Client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
return client.search.search(request)
}
func (client *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return client.searchCommits.search(request)
}
func (client *Client) Close() error {
return nil
}
func CreateGitlabClientWithToken(ctx context.Context, token string, repo clients.Repo) (clients.RepoClient, error) {
client, err := gitlab.NewClient(token, gitlab.WithBaseURL(repo.URI()))
if err != nil {
return nil, fmt.Errorf("could not create gitlab client with error: %w", err)
}
return &Client{
ctx: ctx,
glClient: client,
contributors: &contributorsHandler{
glClient: client,
},
branches: &branchesHandler{
glClient: client,
},
releases: &releasesHandler{
glClient: client,
},
workflows: &workflowsHandler{
glClient: client,
},
checkruns: &checkrunsHandler{
glClient: client,
},
commits: &commitsHandler{
glClient: client,
},
issues: &issuesHandler{
glClient: client,
},
project: &projectHandler{
glClient: client,
},
statuses: &statusesHandler{
glClient: client,
},
search: &searchHandler{
glClient: client,
},
searchCommits: &searchCommitsHandler{
glClient: client,
},
webhook: &webhookHandler{
glClient: client,
},
languages: &languagesHandler{
glClient: client,
},
}, nil
}
// TODO(#2266): implement CreateOssFuzzRepoClient.
func CreateOssFuzzRepoClient(ctx context.Context, logger *log.Logger) (clients.RepoClient, error) {
return nil, fmt.Errorf("%w, oss fuzz currently only supported for github repos", clients.ErrUnsupportedFeature)
}

View File

@ -0,0 +1,165 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type commitsHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
commits []clients.Commit
}
func (handler *commitsHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
// nolint: gocognit
func (handler *commitsHandler) setup() error {
handler.once.Do(func() {
commits, _, err := handler.glClient.Commits.ListCommits(handler.repourl.projectID, &gitlab.ListCommitsOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("request for commits failed with %w", err)
return
}
// To limit the number of user requests we are going to map every committer email
// to a user.
userToEmail := make(map[string]*gitlab.User)
for _, commit := range commits {
user, ok := userToEmail[commit.AuthorEmail]
if !ok {
users, _, err := handler.glClient.Search.Users(commit.CommitterName, &gitlab.SearchOptions{})
if err != nil {
// Possibility this shouldn't be an issue as individuals can leave organizations
// (possibly taking their account with them)
handler.errSetup = fmt.Errorf("unable to find user associated with commit: %w", err)
return
}
// For some reason some users have unknown names, so below we are going to parse their email into pieces.
// i.e. (firstname.lastname@domain.com) -> "firstname lastname".
if len(users) == 0 {
users, _, err = handler.glClient.Search.Users(parseEmailToName(commit.CommitterEmail), &gitlab.SearchOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("unable to find user associated with commit: %w", err)
return
}
}
userToEmail[commit.AuthorEmail] = users[0]
user = users[0]
}
// Commits are able to be a part of multiple merge requests, but the only one that will be important
// here is the earliest one.
mergeRequests, _, err := handler.glClient.Commits.ListMergeRequestsByCommit(handler.repourl.projectID, commit.ID)
if err != nil {
handler.errSetup = fmt.Errorf("unable to find merge requests associated with commit: %w", err)
return
}
var mergeRequest *gitlab.MergeRequest
if len(mergeRequests) > 0 {
mergeRequest = mergeRequests[0]
for i := range mergeRequests {
if mergeRequests[i] == nil || mergeRequests[i].MergedAt == nil {
continue
}
if mergeRequests[i].CreatedAt.Before(*mergeRequest.CreatedAt) {
mergeRequest = mergeRequests[i]
}
}
} else {
handler.commits = append(handler.commits, clients.Commit{
CommittedDate: *commit.CommittedDate,
Message: commit.Message,
SHA: commit.ID,
})
continue
}
if mergeRequest == nil || mergeRequest.MergedAt == nil {
handler.commits = append(handler.commits, clients.Commit{
CommittedDate: *commit.CommittedDate,
Message: commit.Message,
SHA: commit.ID,
})
}
// Casting the Reviewers into clients.Review.
var reviews []clients.Review
for _, reviewer := range mergeRequest.Reviewers {
reviews = append(reviews, clients.Review{
Author: &clients.User{ID: int64(reviewer.ID)},
State: "",
})
}
// Casting the Labels into []clients.Label.
var labels []clients.Label
for _, label := range mergeRequest.Labels {
labels = append(labels, clients.Label{
Name: label,
})
}
// append the commits to the handler.
handler.commits = append(handler.commits,
clients.Commit{
CommittedDate: *commit.CommittedDate,
Message: commit.Message,
SHA: commit.ID,
AssociatedMergeRequest: clients.PullRequest{
Number: mergeRequest.ID,
MergedAt: *mergeRequest.MergedAt,
HeadSHA: mergeRequest.SHA,
Author: clients.User{ID: int64(mergeRequest.Author.ID)},
Labels: labels,
Reviews: reviews,
},
Committer: clients.User{ID: int64(user.ID)},
})
}
})
return handler.errSetup
}
func (handler *commitsHandler) listCommits() ([]clients.Commit, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during commitsHandler.setup: %w", err)
}
return handler.commits, nil
}
// Expected email form: <firstname>.<lastname>@<namespace>.com.
func parseEmailToName(email string) string {
s := strings.Split(email, ".")
firstName := s[0]
lastName := strings.Split(s[1], "@")[0]
return firstName + " " + lastName
}

View File

@ -0,0 +1,92 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type contributorsHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
contributors []clients.User
}
func (handler *contributorsHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *contributorsHandler) setup() error {
handler.once.Do(func() {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
handler.errSetup = fmt.Errorf("%w: ListContributors only supported for HEAD queries",
clients.ErrUnsupportedFeature)
return
}
contribs, _, err := handler.glClient.Repositories.Contributors(
handler.repourl.projectID, &gitlab.ListContributorsOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("error during ListContributors: %w", err)
return
}
for _, contrib := range contribs {
if contrib.Name == "" {
continue
}
// In Gitlab users only have one registered organization which is the company they work for, this means that
// the organizations field will not be filled in and the companies field will be a singular value.
users, _, err := handler.glClient.Search.Users(contrib.Name, &gitlab.SearchOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("error during Users.Get: %w", err)
return
} else if len(users) == 0 {
// parseEmailToName is declared in commits.go
users, _, err = handler.glClient.Search.Users(parseEmailToName(contrib.Email), &gitlab.SearchOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("error during Users.Get: %w", err)
return
}
}
contributor := clients.User{
Login: contrib.Email,
Companies: []string{users[0].Organization},
NumContributions: contrib.Commits,
ID: int64(users[0].ID),
}
handler.contributors = append(handler.contributors, contributor)
}
})
return handler.errSetup
}
func (handler *contributorsHandler) getContributors() ([]clients.User, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during contributorsHandler.setup: %w", err)
}
return handler.contributors, nil
}

View File

@ -0,0 +1,118 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type issuesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
issues []clients.Issue
}
func (handler *issuesHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *issuesHandler) setup() error {
handler.once.Do(func() {
issues, _, err := handler.glClient.Issues.ListProjectIssues(
handler.repourl.projectID, &gitlab.ListProjectIssuesOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("unable to find issues associated with the project id: %w", err)
return
}
// There doesn't seem to be a good way to get user access_levels in gitlab so the following way may seem incredibly
// barberic, however I couldn't find a better way in the docs.
projectAccessTokens, resp, err := handler.glClient.ProjectAccessTokens.ListProjectAccessTokens(
handler.repourl.projectID, &gitlab.ListProjectAccessTokensOptions{})
if err != nil && resp.StatusCode != 401 {
handler.errSetup = fmt.Errorf("unable to find access tokens associated with the project id: %w", err)
return
} else if resp.StatusCode == 401 {
handler.errSetup = fmt.Errorf("insufficient permissions to check issue author associations %w", err)
return
}
if len(issues) > 0 {
for _, issue := range issues {
authorAssociation := clients.RepoAssociationMember
if resp.StatusCode != 401 {
authorAssociation = findAuthorAssociationFromUserID(projectAccessTokens, issue.Author.ID)
}
issueIDString := fmt.Sprint(issue.ID)
handler.issues = append(handler.issues,
clients.Issue{
URI: &issueIDString,
CreatedAt: issue.CreatedAt,
Author: &clients.User{
ID: int64(issue.Author.ID),
},
AuthorAssociation: &authorAssociation,
Comments: nil,
})
}
} else {
handler.issues = nil
}
})
return handler.errSetup
}
func (handler *issuesHandler) listIssues() ([]clients.Issue, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during issuesHandler.setup: %w", err)
}
return handler.issues, nil
}
func findAuthorAssociationFromUserID(accessTokens []*gitlab.ProjectAccessToken, targetID int) clients.RepoAssociation {
for _, accessToken := range accessTokens {
if accessToken.UserID == targetID {
switch accessToken.AccessLevel {
case 0:
return clients.RepoAssociationNone
case 5:
return clients.RepoAssociationFirstTimeContributor
case 10:
return clients.RepoAssociationCollaborator
case 20:
return clients.RepoAssociationCollaborator
case 30:
return clients.RepoAssociationMember
case 40:
return clients.RepoAssociationMaintainer
case 50:
return clients.RepoAssociationOwner
default:
return clients.RepoAssociationNone
}
}
}
return clients.RepoAssociationNone
}

View File

@ -0,0 +1,71 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type languagesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
languages []clients.Language
}
func (handler *languagesHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *languagesHandler) setup() error {
handler.once.Do(func() {
client := handler.glClient
languageMap, _, err := client.Projects.GetProjectLanguages(handler.repourl.projectID)
if err != nil || languageMap == nil {
handler.errSetup = fmt.Errorf("request for repo languages failed with %w", err)
return
}
// TODO(#2266): find number of lines of gitlab project and multiple the value of each language by that number.
for k, v := range *languageMap {
handler.languages = append(handler.languages,
clients.Language{
Name: clients.LanguageName(k),
NumLines: int(v * 100),
},
)
}
handler.errSetup = nil
})
return handler.errSetup
}
// Currently listProgrammingLanguages() returns the percentages (truncated) of each language in the project.
func (handler *languagesHandler) listProgrammingLanguages() ([]clients.Language, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during languagesHandler.setup: %w", err)
}
return handler.languages, nil
}

View File

@ -0,0 +1,69 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"sync"
"time"
"github.com/xanzy/go-gitlab"
)
type projectHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
createdAt time.Time
archived bool
}
func (handler *projectHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *projectHandler) setup() error {
handler.once.Do(func() {
proj, _, err := handler.glClient.Projects.GetProject(handler.repourl.projectID, &gitlab.GetProjectOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("request for project failed with error %w", err)
return
}
handler.createdAt = *proj.CreatedAt
handler.archived = proj.Archived
})
return handler.errSetup
}
func (handler *projectHandler) isArchived() (bool, error) {
if err := handler.setup(); err != nil {
return true, fmt.Errorf("error during projectHandler.setup: %w", err)
}
return handler.archived, nil
}
func (handler *projectHandler) getCreatedAt() (time.Time, error) {
if err := handler.setup(); err != nil {
return time.Now(), fmt.Errorf("error during projectHandler.setup: %w", err)
}
return handler.createdAt, nil
}

View File

@ -0,0 +1,85 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type releasesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
releases []clients.Release
}
func (handler *releasesHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *releasesHandler) setup() error {
handler.once.Do(func() {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
handler.errSetup = fmt.Errorf("%w: ListReleases only supported for HEAD queries", clients.ErrUnsupportedFeature)
return
}
releases, _, err := handler.glClient.Releases.ListReleases(handler.repourl.projectID, &gitlab.ListReleasesOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("%w: ListReleases failed", err)
return
}
if len(releases) > 0 {
handler.releases = releasesFrom(releases)
} else {
handler.releases = nil
}
})
return handler.errSetup
}
func (handler *releasesHandler) getReleases() ([]clients.Release, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during Releases.setup: %w", err)
}
return handler.releases, nil
}
func releasesFrom(data []*gitlab.Release) []clients.Release {
var releases []clients.Release
for _, r := range data {
release := clients.Release{
TagName: r.TagName,
URL: r.Assets.Links[0].DirectAssetURL,
TargetCommitish: r.CommitPath,
}
for _, a := range r.Assets.Sources {
release.Assets = append(release.Assets, clients.ReleaseAsset{
Name: a.Format,
URL: a.URL,
})
}
releases = append(releases, release)
}
return releases
}

128
clients/gitlabrepo/repo.go Normal file
View File

@ -0,0 +1,128 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// NOTE: In Gitlab repositories are called projects, however to ensure compatibility,
// this package will regard to Gitlab projects as repositories.
package gitlabrepo
import (
"fmt"
"regexp"
"strings"
"github.com/ossf/scorecard/v4/clients"
sce "github.com/ossf/scorecard/v4/errors"
)
const (
gitlabOrgProj = ".gitlab"
)
type repoURL struct {
hostname string
owner string
projectID string
defaultBranch string
commitSHA string
metadata []string
}
// Parses input string into repoURL struct
/*
* Accepted input string formats are as follows:
* "gitlab.<companyDomain:string>.com/<owner:string>/<projectID:int>"
* "https://gitlab.<companyDomain:string>.com/<owner:string>/<projectID:int>"
*/
func (r *repoURL) parse(input string) error {
switch {
case strings.Contains(input, "https://"):
input = strings.TrimPrefix(input, "https://")
case strings.Contains(input, "http://"):
input = strings.TrimPrefix(input, "http://")
case strings.Contains(input, "://"):
return sce.WithMessage(sce.ErrScorecardInternal, "unknown input format")
}
stringParts := strings.Split(input, "/")
stringParts[2] = strings.TrimSuffix(stringParts[2], "/")
r.hostname, r.owner, r.projectID = stringParts[0], stringParts[1], stringParts[2]
return nil
}
// URI implements Repo.URI().
// TODO: there may be a reason the string was originally in format "%s/%s/%s", hostname, owner, projectID,
// however I changed it to be more "userful".
func (r *repoURL) URI() string {
return fmt.Sprintf("https://%s", r.hostname)
}
// String implements Repo.String.
func (r *repoURL) String() string {
return fmt.Sprintf("%s-%s_%s", r.hostname, r.owner, r.projectID)
}
func (r *repoURL) Org() clients.Repo {
return &repoURL{
hostname: r.hostname,
owner: r.owner,
projectID: gitlabOrgProj,
}
}
// IsValid implements Repo.IsValid.
func (r *repoURL) IsValid() error {
hostMatched, err := regexp.MatchString("gitlab.*com", r.hostname)
if err != nil {
return fmt.Errorf("error processing regex: %w", err)
}
if !hostMatched {
return sce.WithMessage(sce.ErrorInvalidURL, "non gitlab repository found")
}
isNotDigit := func(c rune) bool { return c < '0' || c > '9' }
b := strings.IndexFunc(r.projectID, isNotDigit) == -1
if !b {
return sce.WithMessage(sce.ErrorInvalidURL, "incorrect format for projectID")
}
if strings.TrimSpace(r.owner) == "" || strings.TrimSpace(r.projectID) == "" {
return sce.WithMessage(sce.ErrorInvalidURL,
fmt.Sprintf("%v. Expected the full project url", r.URI()))
}
return nil
}
func (r *repoURL) AppendMetadata(metadata ...string) {
r.metadata = append(r.metadata, metadata...)
}
// Metadata implements Repo.Metadata.
func (r *repoURL) Metadata() []string {
return r.metadata
}
// MakeGitlabRepo takes input of forms in parse and returns and implementation
// of clients.Repo interface.
func MakeGitlabRepo(input string) (clients.Repo, error) {
var repo repoURL
if err := repo.parse(input); err != nil {
return nil, fmt.Errorf("error during parse: %w", err)
}
if err := repo.IsValid(); err != nil {
return nil, fmt.Errorf("error n IsValid: %w", err)
}
return &repo, nil
}

View File

@ -0,0 +1,136 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestRepoURL_IsValid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputURL string
expected repoURL
wantErr bool
}{
{
name: "valid http address",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "1234",
},
inputURL: "http://gitlab.example.com/foo/1234",
wantErr: false,
},
{
name: "valid https address",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "1234",
},
inputURL: "https://gitlab.example.com/foo/1234",
wantErr: false,
},
{
name: "valid http address with trailing slash",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "1234",
},
inputURL: "http://gitlab.example.com/foo/1234/",
wantErr: false,
},
{
name: "valid https address with trailing slash",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "1234",
},
inputURL: "https://gitlab.example.com/foo/1234/",
wantErr: false,
},
{
name: "non gitlab repository",
expected: repoURL{
hostname: "github.com",
owner: "foo",
projectID: "1234",
},
inputURL: "https://github.com/foo/1234",
wantErr: true,
},
{
name: "GitLab project with wrong projectID",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "bar",
},
inputURL: "https://gitlab.example.com/foo/bar",
wantErr: true,
},
{
name: "GitHub project with 'gitlab.' in the title",
expected: repoURL{
hostname: "github.com",
owner: "foo",
projectID: "gitlab.test",
},
inputURL: "http://github.com/foo/gitlab.test",
wantErr: true,
},
{
name: "valid gitlab project without http or https",
expected: repoURL{
hostname: "gitlab.example.com",
owner: "foo",
projectID: "1234",
},
inputURL: "gitlab.example.com/foo/1234",
wantErr: false,
},
}
for _, tt := range tests {
tt := tt // Re-initializing variable so it is not changed while executing the closure blow
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := repoURL{
hostname: tt.expected.hostname,
owner: tt.expected.owner,
projectID: tt.expected.projectID,
}
if err := r.parse(tt.inputURL); err != nil {
t.Errorf("repoURL.parse() error = %v", err)
}
if err := r.IsValid(); (err != nil) != tt.wantErr {
t.Errorf("repoURL.IsValid() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && !cmp.Equal(tt.expected, r, cmp.AllowUnexported(repoURL{})) {
fmt.Println("expected: " + tt.expected.hostname + " GOT: " + r.hostname)
fmt.Println("expected: " + tt.expected.owner + " GOT: " + r.owner)
fmt.Println("expected: " + tt.expected.projectID + " GOT: " + r.projectID)
t.Errorf("Got diff: %s", cmp.Diff(tt.expected, r))
}
})
}
}

View File

@ -0,0 +1,94 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"errors"
"fmt"
"strings"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
var errEmptyQuery = errors.New("search query is empty")
type searchHandler struct {
glClient *gitlab.Client
repourl *repoURL
}
func (handler *searchHandler) init(repourl *repoURL) {
handler.repourl = repourl
}
func (handler *searchHandler) search(request clients.SearchRequest) (clients.SearchResponse, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return clients.SearchResponse{}, fmt.Errorf(
"%w: Search only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
query, err := handler.buildQuery(request)
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("handler.buildQuery: %w", err)
}
blobs, _, err := handler.glClient.Search.BlobsByProject(handler.repourl.projectID, query, &gitlab.SearchOptions{})
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("Search.BlobsByProject: %w", err)
}
return searchResponseFrom(blobs), nil
}
func (handler *searchHandler) buildQuery(request clients.SearchRequest) (string, error) {
if request.Query == "" {
return "", fmt.Errorf("%w", errEmptyQuery)
}
var queryBuilder strings.Builder
if _, err := queryBuilder.WriteString(
fmt.Sprintf("%s project:%s/%s",
strings.ReplaceAll(request.Query, "/", " "),
handler.repourl.owner, handler.repourl.projectID)); err != nil {
return "", fmt.Errorf("WriteString: %w", err)
}
if request.Filename != "" {
if _, err := queryBuilder.WriteString(
fmt.Sprintf(" in:file filename:%s", request.Filename)); err != nil {
return "", fmt.Errorf("WriteString: %w", err)
}
}
if request.Path != "" {
if _, err := queryBuilder.WriteString(fmt.Sprintf(" path:%s", request.Path)); err != nil {
return "", fmt.Errorf("WriteString: %w", err)
}
}
return queryBuilder.String(), nil
}
// There is a possibility that path should be Basename/Filename for blobs.
func searchResponseFrom(blobs []*gitlab.Blob) clients.SearchResponse {
var searchResults []clients.SearchResult
for _, blob := range blobs {
searchResults = append(searchResults, clients.SearchResult{
Path: blob.Filename,
})
}
ret := clients.SearchResponse{
Results: searchResults,
Hits: len(searchResults),
}
return ret
}

View File

@ -0,0 +1,84 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type searchCommitsHandler struct {
glClient *gitlab.Client
repourl *repoURL
}
func (handler *searchCommitsHandler) init(repourl *repoURL) {
handler.repourl = repourl
}
func (handler *searchCommitsHandler) search(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return nil, fmt.Errorf("%w: Search only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
query, err := handler.buildQuery(request)
if err != nil {
return nil, fmt.Errorf("handler.buildQuiery: %w", err)
}
commits, _, err := handler.glClient.Search.CommitsByProject(handler.repourl.projectID, query, &gitlab.SearchOptions{})
if err != nil {
return nil, fmt.Errorf("Search.Commits: %w", err)
}
// Gitlab returns a list of commits that does not contain the committer's id, unlike in
// githubrepo/searchCommits.go so to limit the number of requests we are mapping each unique user
// email to thei gitlab user data.
userMap := make(map[string]*gitlab.User)
var ret []clients.Commit
for _, commit := range commits {
if _, ok := userMap[commit.CommitterEmail]; !ok {
user, _, err := handler.glClient.Search.Users(commit.CommitterEmail, &gitlab.SearchOptions{})
if err != nil {
return nil, fmt.Errorf("gitlab-searchCommits: %w", err)
}
userMap[commit.CommitterEmail] = user[0]
}
ret = append(ret, clients.Commit{
Committer: clients.User{ID: int64(userMap[commit.CommitterEmail].ID)},
})
}
return ret, nil
}
func (handler *searchCommitsHandler) buildQuery(request clients.SearchCommitsOptions) (string, error) {
if request.Author == "" {
return "", fmt.Errorf("%w", errEmptyQuery)
}
var queryBuilder strings.Builder
if _, err := queryBuilder.WriteString(
fmt.Sprintf("project:%s/%s author:%s",
handler.repourl.owner, handler.repourl.projectID,
request.Author)); err != nil {
return "", fmt.Errorf("writestring: %w", err)
}
return queryBuilder.String(), nil
}

View File

@ -0,0 +1,77 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"errors"
"testing"
"github.com/ossf/scorecard/v4/clients"
)
func TestSearchCommitsBuildQuery(t *testing.T) {
t.Parallel()
testcases := []struct {
searchReq clients.SearchCommitsOptions
expectedErrType error
name string
repourl *repoURL
expectedQuery string
hasError bool
}{
{
name: "Basic",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchCommitsOptions{
Author: "testAuthor",
},
expectedQuery: "project:testowner/1234 author:testAuthor",
},
{
name: "EmptyQuery:",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchCommitsOptions{},
hasError: true,
expectedErrType: errEmptyQuery,
},
}
for _, testcase := range testcases {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
handler := searchCommitsHandler{
repourl: testcase.repourl,
}
query, err := handler.buildQuery(testcase.searchReq)
if !testcase.hasError && err != nil {
t.Fatalf("expected - no error, get: %v", err)
}
if testcase.hasError && !errors.Is(err, testcase.expectedErrType) {
t.Fatalf("expectedErrType - %v, got - %v", testcase.expectedErrType, err)
} else if query != testcase.expectedQuery {
t.Fatalf("expectedQuery - %s, got - %s", testcase.expectedQuery, query)
}
})
}
}

View File

@ -0,0 +1,127 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"errors"
"testing"
"github.com/ossf/scorecard/v4/clients"
)
func TestBuildQuery(t *testing.T) {
t.Parallel()
testcases := []struct {
searchReq clients.SearchRequest
expectedErrType error
name string
repourl *repoURL
expectedQuery string
hasError bool
}{
{
name: "Basic",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{
Query: "testquery",
},
expectedQuery: "testquery project:testowner/1234",
},
{
name: "EmptyQuery",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{},
hasError: true,
expectedErrType: errEmptyQuery,
},
{
name: "WithFilename",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{
Query: "testquery",
Filename: "filename1.txt",
},
expectedQuery: "testquery project:testowner/1234 in:file filename:filename1.txt",
},
{
name: "WithPath",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{
Query: "testquery",
Path: "dir1/file1.txt",
},
expectedQuery: "testquery project:testowner/1234 path:dir1/file1.txt",
},
{
name: "WithFilenameAndPath",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{
Query: "testquery",
Filename: "filename1.txt",
Path: "dir1/dir2",
},
expectedQuery: "testquery project:testowner/1234 in:file filename:filename1.txt path:dir1/dir2",
},
{
name: "WithFilenameAndPathWithSeperator",
repourl: &repoURL{
owner: "testowner",
projectID: "1234",
},
searchReq: clients.SearchRequest{
Query: "testquery/query",
Filename: "filename1.txt",
Path: "dir1/dir2",
},
expectedQuery: "testquery query project:testowner/1234 in:file filename:filename1.txt path:dir1/dir2",
},
}
for _, testcase := range testcases {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
handler := searchHandler{
repourl: testcase.repourl,
}
query, err := handler.buildQuery(testcase.searchReq)
if !testcase.hasError && err != nil {
t.Fatalf("expected - no error, got: %v", err)
}
if testcase.hasError && !errors.Is(err, testcase.expectedErrType) {
t.Fatalf("expectedErrType - %v, got -%v", testcase.expectedErrType, err)
} else if query != testcase.expectedQuery {
t.Fatalf("expectedQuery - %s, got - %s", testcase.expectedQuery, query)
}
})
}
}

View File

@ -0,0 +1,55 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type statusesHandler struct {
glClient *gitlab.Client
repourl *repoURL
}
func (handler *statusesHandler) init(repourl *repoURL) {
handler.repourl = repourl
}
// for gitlab this only works if ref is SHA.
func (handler *statusesHandler) listStatuses(ref string) ([]clients.Status, error) {
commitStatuses, _, err := handler.glClient.Commits.GetCommitStatuses(
handler.repourl.projectID, ref, &gitlab.GetCommitStatusesOptions{})
if err != nil {
return nil, fmt.Errorf("error getting commit statuses: %w", err)
}
return statusFromData(commitStatuses), nil
}
func statusFromData(commitStatuses []*gitlab.CommitStatus) []clients.Status {
var statuses []clients.Status
for _, commitStatus := range commitStatuses {
statuses = append(statuses, clients.Status{
State: commitStatus.Status,
Context: fmt.Sprint(commitStatus.ID),
URL: commitStatus.TargetURL,
TargetURL: commitStatus.TargetURL,
})
}
return statuses
}

View File

@ -0,0 +1,69 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"sync"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type webhookHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *repoURL
webhooks []clients.Webhook
}
func (handler *webhookHandler) init(repourl *repoURL) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
}
func (handler *webhookHandler) setup() error {
handler.once.Do(func() {
projectHooks, _, err := handler.glClient.Projects.ListProjectHooks(
handler.repourl.projectID, &gitlab.ListProjectHooksOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("request for project hooks failed with %w", err)
return
}
// TODO: make sure that enablesslverification is similarly equivalent to auth secret.
for _, hook := range projectHooks {
handler.webhooks = append(handler.webhooks,
clients.Webhook{
Path: hook.URL,
ID: int64(hook.ID),
UsesAuthSecret: hook.EnableSSLVerification,
})
}
})
return handler.errSetup
}
func (handler *webhookHandler) listWebhooks() ([]clients.Webhook, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during webhookHandler.setup: %w", err)
}
return handler.webhooks, nil
}

View File

@ -0,0 +1,62 @@
// Copyright 2022 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gitlabrepo
import (
"fmt"
"strings"
"github.com/xanzy/go-gitlab"
"github.com/ossf/scorecard/v4/clients"
)
type workflowsHandler struct {
glClient *gitlab.Client
repourl *repoURL
}
func (handler *workflowsHandler) init(repourl *repoURL) {
handler.repourl = repourl
}
func (handler *workflowsHandler) listSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
var buildStates []gitlab.BuildStateValue
buildStates = append(buildStates, gitlab.Success)
jobs, _, err := handler.glClient.Jobs.ListProjectJobs(handler.repourl.projectID,
&gitlab.ListJobsOptions{Scope: &buildStates})
if err != nil {
return nil, fmt.Errorf("error getting project jobs: %w", err)
}
return workflowsRunsFrom(jobs, filename), nil
}
func workflowsRunsFrom(data []*gitlab.Job, filename string) []clients.WorkflowRun {
var workflowRuns []clients.WorkflowRun
for _, job := range data {
// Find a better way to do this.
for _, artifact := range job.Artifacts {
if strings.EqualFold(artifact.Filename, filename) {
workflowRuns = append(workflowRuns, clients.WorkflowRun{
HeadSHA: &job.Pipeline.Sha,
URL: job.WebURL,
})
continue
}
}
}
return workflowRuns
}

View File

@ -34,18 +34,24 @@ const (
// Mannequin: Author is a placeholder for an unclaimed user.
RepoAssociationMannequin RepoAssociation = iota
// None: Author has no association with the repository.
// NoPermissions: (GitLab).
RepoAssociationNone
// FirstTimer: Author has not previously committed to the VCS.
RepoAssociationFirstTimer
// FirstTimeContributor: Author has not previously committed to the repository.
// MinimalAccessPermissions: (Gitlab).
RepoAssociationFirstTimeContributor
// Contributor: Author has been a contributor to the repository.
RepoAssociationContributor
// Collaborator: Author has been invited to collaborate on the repository.
RepoAssociationCollaborator
// Member: Author is a member of the organization that owns the repository.
// DeveloperAccessPermissions: (GitLab).
RepoAssociationMember
// Maintainer: Author is part of the maintenance team for the repository (GitLab).
RepoAssociationMaintainer
// Owner: Author is the owner of the repository.
// (Owner): (GitLab).
RepoAssociationOwner
)

13
go.mod
View File

@ -50,6 +50,12 @@ require (
sigs.k8s.io/release-utils v0.6.0
)
require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
)
require (
cloud.google.com/go v0.104.0 // indirect
cloud.google.com/go/compute v1.7.0 // indirect
@ -101,15 +107,16 @@ require (
github.com/sergi/go-diff v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/vbatts/tar-split v0.11.2 // indirect
github.com/xanzy/go-gitlab v0.73.1
github.com/xanzy/ssh-agent v0.3.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 // indirect
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48 // indirect
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/api v0.93.0 // indirect
google.golang.org/appengine v1.6.7 // indirect

25
go.sum
View File

@ -415,6 +415,13 @@ github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok=
github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@ -594,12 +601,14 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/vbatts/tar-split v0.11.2 h1:Via6XqJr0hceW4wff3QRzD5gAk/tatMw/4ZA7cTlIME=
github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI=
github.com/xanzy/go-gitlab v0.73.1 h1:UMagqUZLJdjss1SovIC+kJCH4k2AZWXl58gJd38Y/hI=
github.com/xanzy/go-gitlab v0.73.1/go.mod h1:d/a0vswScO7Agg1CZNz15Ic6SSvBG9vfw8egL99t4kA=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
@ -751,8 +760,8 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48 h1:N9Vc/rorQUDes6B9CNdIxAn5jODGj2wzfrei2x4wNj4=
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -776,8 +785,8 @@ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 h1:oVlhw3Oe+1reYsE2Nqu19PDJfLzwdU3QUUrG86rLK68=
golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c h1:q3gFqPqH7NVofKo3c3yETAP//pPI+G5mvB7qqj1Y5kY=
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -875,8 +884,8 @@ golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@ -896,6 +905,8 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=