// Copyright 2020 OpenSSF 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 checker
import (
"context"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/internal/packageclient"
)
// CheckRequest struct encapsulates all data to be passed into a CheckFn.
type CheckRequest struct {
Ctx context.Context
RepoClient clients.RepoClient
CIIClient clients.CIIBestPracticesClient
OssFuzzRepo clients.RepoClient
Dlogger DetailLogger
Repo clients.Repo
VulnerabilitiesClient clients.VulnerabilitiesClient
ProjectClient packageclient.ProjectPackageClient
// UPGRADEv6: return raw results instead of scores.
RawResults *RawResults
RequiredTypes []RequestType
}
// RequestType identifies special requirements/attributes that need to be supported by checks.
type RequestType int
const (
// FileBased request types require checks to run solely on file-content.
FileBased RequestType = iota
// CommitBased request types require checks to run on non-HEAD commit content.
CommitBased
)
// ListUnsupported returns []RequestType not in `supported` and are `required`.
func ListUnsupported(required, supported []RequestType) []RequestType {
var ret []RequestType
for _, t := range required {
if !contains(supported, t) {
ret = append(ret, t)
}
}
return ret
}
func contains(in []RequestType, exists RequestType) bool {
for _, r := range in {
if r == exists {
return true
}
}
return false
}
// Copyright 2020 OpenSSF 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 checker includes structs and functions used for running a check.
package checker
import (
"errors"
"fmt"
"math"
"strings"
"github.com/ossf/scorecard/v5/config"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
type (
// DetailType is the type of details.
DetailType int
)
const (
// MaxResultScore is the best score that can be given by a check.
MaxResultScore = 10
// MinResultScore is the worst score that can be given by a check.
MinResultScore = 0
// InconclusiveResultScore is returned when no reliable information can be retrieved by a check.
InconclusiveResultScore = -1
// OffsetDefault is used if we can't determine the offset, for example when referencing a file but not a
// specific location in the file.
OffsetDefault = uint(1)
)
const (
// DetailInfo is info-level log.
DetailInfo DetailType = iota
// DetailWarn is warned log.
DetailWarn
// DetailDebug is debug log.
DetailDebug
)
// errSuccessTotal indicates a runtime error because number of success cases should
// be smaller than the total cases to create a proportional score.
var errSuccessTotal = errors.New("unexpected number of success is higher than total")
// CheckResult captures result from a check run.
//
//nolint:govet
type CheckResult struct {
Name string
Version int
Error error
Score int
Reason string
Details []CheckDetail
// Findings from the check's probes.
Findings []finding.Finding
}
// CheckDetail contains information for each detail.
type CheckDetail struct {
Msg LogMessage
Type DetailType // Any of DetailWarn, DetailInfo, DetailDebug.
}
// LogMessage is a structure that encapsulates detail's information.
// This allows updating the definition easily.
//
//nolint:govet
type LogMessage struct {
// Structured results.
Finding *finding.Finding
// Non-structured results.
Text string // A short string explaining why the detail was recorded/logged.
Path string // Fullpath to the file.
Type finding.FileType // Type of file.
Offset uint // Offset in the file of Path (line for source/text files).
EndOffset uint // End of offset in the file, e.g. if the command spans multiple lines.
Snippet string // Snippet of code
Remediation *finding.Remediation // Remediation information, if any.
}
// ProportionalScoreWeighted is a structure that contains
// the fields to calculate weighted proportional scores.
type ProportionalScoreWeighted struct {
Success int
Total int
Weight int
}
// CreateProportionalScore creates a proportional score.
func CreateProportionalScore(success, total int) int {
if total == 0 {
return 0
}
return min(MaxResultScore*success/total, MaxResultScore)
}
// CreateProportionalScoreWeighted creates the proportional score
// between multiple successes over the total, but some proportions
// are worth more.
func CreateProportionalScoreWeighted(scores ...ProportionalScoreWeighted) (int, error) {
var ws, wt int
allWeightsZero := true
noScoreGroups := true
for _, score := range scores {
if score.Success > score.Total {
return InconclusiveResultScore, fmt.Errorf("%w: %d, %d", errSuccessTotal, score.Success, score.Total)
}
if score.Total == 0 {
continue // Group with 0 total, does not count for score
}
noScoreGroups = false
if score.Weight != 0 {
allWeightsZero = false
}
// Group with zero weight, adds nothing to the score
ws += score.Success * score.Weight
wt += score.Total * score.Weight
}
if noScoreGroups {
return InconclusiveResultScore, nil
}
// If has score groups but no groups matter to the score, result in max score
if allWeightsZero {
return MaxResultScore, nil
}
return min(MaxResultScore*ws/wt, MaxResultScore), nil
}
// AggregateScores adds up all scores
// and normalizes the result.
// Each score contributes equally.
func AggregateScores(scores ...int) int {
n := float64(len(scores))
r := 0
for _, s := range scores {
r += s
}
return int(math.Floor(float64(r) / n))
}
// AggregateScoresWithWeight adds up all scores
// and normalizes the result.
func AggregateScoresWithWeight(scores map[int]int) int {
r := 0
ws := 0
for s, w := range scores {
r += s * w
ws += w
}
return int(math.Floor(float64(r) / float64(ws)))
}
// NormalizeReason - placeholder function if we want to update range of scores.
func NormalizeReason(reason string, score int) string {
return fmt.Sprintf("%v -- score normalized to %d", reason, score)
}
// CreateResultWithScore is used when
// the check runs without runtime errors, and we want to assign a
// specific score. The score must be between [MinResultScore] and [MaxResultScore].
// Callers who want [InconclusiveResultScore] must use [CreateInconclusiveResult] instead.
//
// Passing an invalid score results in a runtime error result as if created by [CreateRuntimeErrorResult].
func CreateResultWithScore(name, reason string, score int) CheckResult {
if score < MinResultScore || score > MaxResultScore {
err := sce.CreateInternal(sce.ErrScorecardInternal, fmt.Sprintf("invalid score (%d), please report this", score))
return CreateRuntimeErrorResult(name, err)
}
return CheckResult{
Name: name,
Version: 2,
Error: nil,
Score: score,
Reason: reason,
}
}
// CreateProportionalScoreResult is used when
// the check runs without runtime errors and we assign a
// proportional score. This may be used if a check contains
// multiple tests, and we want to assign a score proportional
// the number of tests that succeeded.
func CreateProportionalScoreResult(name, reason string, b, t int) CheckResult {
score := CreateProportionalScore(b, t)
reason = NormalizeReason(reason, score)
return CreateResultWithScore(name, reason, score)
}
// CreateMaxScoreResult is used when
// the check runs without runtime errors and we can assign a
// maximum score to the result.
func CreateMaxScoreResult(name, reason string) CheckResult {
return CreateResultWithScore(name, reason, MaxResultScore)
}
// CreateMinScoreResult is used when
// the check runs without runtime errors and we can assign a
// minimum score to the result.
func CreateMinScoreResult(name, reason string) CheckResult {
return CreateResultWithScore(name, reason, MinResultScore)
}
// CreateInconclusiveResult is used when
// the check runs without runtime errors, but we don't
// have enough evidence to set a score.
func CreateInconclusiveResult(name, reason string) CheckResult {
return CheckResult{
Name: name,
Version: 2,
Score: InconclusiveResultScore,
Reason: reason,
}
}
// CreateRuntimeErrorResult is used when the check fails to run because of a runtime error.
func CreateRuntimeErrorResult(name string, e error) CheckResult {
return CheckResult{
Name: name,
Version: 2,
Error: e,
Score: InconclusiveResultScore,
Reason: e.Error(), // Note: message already accessible by caller through `Error`.
}
}
// LogFinding logs the given finding at the given level.
func LogFinding(dl DetailLogger, f *finding.Finding, level DetailType) {
lm := LogMessage{Finding: f}
switch level {
case DetailDebug:
dl.Debug(&lm)
case DetailInfo:
dl.Info(&lm)
case DetailWarn:
dl.Warn(&lm)
}
}
// Annotations returns the applicable annotations for a given configuration.
// Any annotations on checks with a maximum score are assumed to be out of
// date and skipped.
func (check *CheckResult) Annotations(c config.Config) []string {
// If check has a maximum score, then there it doesn't make sense anymore to reason the check
// This may happen if the check score was once low but then the problem was fixed on Scorecard side
// or on the maintainers side
if check.Score == MaxResultScore {
return nil
}
// Collect all annotation reasons for this check
var reasons []string
// For all annotations
for _, annotation := range c.Annotations {
for _, checkName := range annotation.Checks {
// If check is in this annotation
if strings.EqualFold(checkName, check.Name) {
// Get all the reasons for this annotation
for _, reasonGroup := range annotation.Reasons {
reasons = append(reasons, reasonGroup.Reason.Doc())
}
}
}
}
return reasons
}
// Copyright 2020 OpenSSF 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 checker
import (
"context"
"errors"
"fmt"
"time"
opencensusstats "go.opencensus.io/stats"
"go.opencensus.io/tag"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/stats"
)
const checkRetries = 3
// Runner runs a check with retries.
type Runner struct {
CheckName string
Repo string
CheckRequest CheckRequest
}
// NewRunner creates a new instance of `Runner`.
func NewRunner(checkName, repo string, checkReq *CheckRequest) *Runner {
return &Runner{
CheckName: checkName,
Repo: repo,
CheckRequest: *checkReq,
}
}
// SetCheckName sets the check name.
func (r *Runner) SetCheckName(check string) {
r.CheckName = check
}
// SetRepo sets the repository.
func (r *Runner) SetRepo(repo string) {
r.Repo = repo
}
// SetCheckRequest sets the check request.
func (r *Runner) SetCheckRequest(checkReq *CheckRequest) {
r.CheckRequest = *checkReq
}
// CheckFn defined for convenience.
type CheckFn func(*CheckRequest) CheckResult
// Check defines a Scorecard check fn and its supported request types.
type Check struct {
Fn CheckFn
SupportedRequestTypes []RequestType
}
// CheckNameToFnMap defined here for convenience.
type CheckNameToFnMap map[string]Check
func logStats(ctx context.Context, startTime time.Time, result *CheckResult) error {
runTimeInSecs := time.Now().Unix() - startTime.Unix()
opencensusstats.Record(ctx, stats.CheckRuntimeInSec.M(runTimeInSecs))
if result.Error != nil {
ctx, err := tag.New(ctx, tag.Upsert(stats.ErrorName, sce.GetName(result.Error)))
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("tag.New: %v", err))
}
opencensusstats.Record(ctx, stats.CheckErrors.M(1))
}
return nil
}
// Run runs a given check.
func (r *Runner) Run(ctx context.Context, c Check) CheckResult {
// Sanity check.
unsupported := ListUnsupported(r.CheckRequest.RequiredTypes, c.SupportedRequestTypes)
if len(unsupported) != 0 {
return CreateRuntimeErrorResult(r.CheckName,
sce.WithMessage(sce.ErrUnsupportedCheck,
fmt.Sprintf("requiredType: %s not supported by check %s", fmt.Sprint(unsupported), r.CheckName)))
}
l := NewLogger()
ctx, err := tag.New(ctx, tag.Upsert(stats.CheckName, r.CheckName))
if err != nil {
l.Warn(&LogMessage{Text: fmt.Sprintf("tag.New: %v", err)})
}
ctx, err = tag.New(ctx, tag.Upsert(stats.RepoHost, r.CheckRequest.Repo.Host()))
if err != nil {
l.Warn(&LogMessage{Text: fmt.Sprintf("tag.New: %v", err)})
}
startTime := time.Now()
var res CheckResult
l = NewLogger()
for retriesRemaining := checkRetries; retriesRemaining > 0; retriesRemaining-- {
checkRequest := r.CheckRequest
checkRequest.Ctx = ctx
checkRequest.Dlogger = l
res = c.Fn(&checkRequest)
if res.Error != nil && errors.Is(res.Error, sce.ErrRepoUnreachable) {
checkRequest.Dlogger.Warn(&LogMessage{
Text: fmt.Sprintf("%v", res.Error),
})
continue
}
break
}
// Set details.
// TODO(#1393): Remove.
res.Details = l.Flush()
if err := logStats(ctx, startTime, &res); err != nil {
panic(err)
}
return res
}
// Copyright 2022 OpenSSF 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 checker
import (
"context"
"fmt"
"os"
"github.com/ossf/scorecard/v5/clients"
azdorepo "github.com/ossf/scorecard/v5/clients/azuredevopsrepo"
ghrepo "github.com/ossf/scorecard/v5/clients/githubrepo"
glrepo "github.com/ossf/scorecard/v5/clients/gitlabrepo"
"github.com/ossf/scorecard/v5/clients/localdir"
"github.com/ossf/scorecard/v5/clients/ossfuzz"
"github.com/ossf/scorecard/v5/internal/packageclient"
"github.com/ossf/scorecard/v5/log"
)
// GetClients returns a list of clients for running scorecard checks.
// TODO(repo): Pass a `http.RoundTripper` here.
func GetClients(ctx context.Context, repoURI, localURI string, logger *log.Logger) (
clients.Repo, // repo
clients.RepoClient, // repoClient
clients.RepoClient, // ossFuzzClient
clients.CIIBestPracticesClient, // ciiClient
clients.VulnerabilitiesClient, // vulnClient
packageclient.ProjectPackageClient, // projectClient
error,
) {
var repo clients.Repo
var makeRepoError error
if localURI != "" {
localRepo, errLocal := localdir.MakeLocalDirRepo(localURI)
var retErr error
if errLocal != nil {
retErr = fmt.Errorf("getting local directory client: %w", errLocal)
}
return localRepo, /*repo*/
localdir.CreateLocalDirClient(ctx, logger), /*repoClient*/
nil, /*ossFuzzClient*/
nil, /*ciiClient*/
clients.DefaultVulnerabilitiesClient(), /*vulnClient*/
nil,
retErr
}
_, experimental := os.LookupEnv("SCORECARD_EXPERIMENTAL")
var repoClient clients.RepoClient
repo, makeRepoError = glrepo.MakeGitlabRepo(repoURI)
if repo != nil && makeRepoError == nil {
repoClient, makeRepoError = glrepo.CreateGitlabClient(ctx, repo.Host())
}
if experimental && (makeRepoError != nil || repo == nil) {
repo, makeRepoError = azdorepo.MakeAzureDevOpsRepo(repoURI)
if repo != nil && makeRepoError == nil {
repoClient, makeRepoError = azdorepo.CreateAzureDevOpsClient(ctx, repo)
}
}
if makeRepoError != nil || repo == nil {
repo, makeRepoError = ghrepo.MakeGithubRepo(repoURI)
if makeRepoError != nil {
return repo,
nil,
nil,
nil,
nil,
packageclient.CreateDepsDevClient(),
fmt.Errorf("error making github repo: %w", makeRepoError)
}
repoClient = ghrepo.CreateGithubRepoClient(ctx, logger)
}
return repo, /*repo*/
repoClient, /*repoClient*/
ossfuzz.CreateOSSFuzzClient(ossfuzz.StatusURL), /*ossFuzzClient*/
clients.DefaultCIIBestPracticesClient(), /*ciiClient*/
clients.DefaultVulnerabilitiesClient(), /*vulnClient*/
packageclient.CreateDepsDevClient(),
nil
}
// Copyright 2020 OpenSSF 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 checker
// Logger is an implementation of the `DetailLogger` interface.
type logger struct {
logs []CheckDetail
}
// NewLogger creates a new instance of `DetailLogger`.
func NewLogger() DetailLogger {
return &logger{}
}
// Info emits info level logs.
func (l *logger) Info(msg *LogMessage) {
cd := CheckDetail{
Type: DetailInfo,
Msg: *msg,
}
l.logs = append(l.logs, cd)
}
// Warn emits warn level logs.
func (l *logger) Warn(msg *LogMessage) {
cd := CheckDetail{
Type: DetailWarn,
Msg: *msg,
}
l.logs = append(l.logs, cd)
}
// Debug emits debug level logs.
func (l *logger) Debug(msg *LogMessage) {
cd := CheckDetail{
Type: DetailDebug,
Msg: *msg,
}
l.logs = append(l.logs, cd)
}
// Flush returns existing logs and resets the logger instance.
func (l *logger) Flush() []CheckDetail {
ret := l.Logs()
l.logs = nil
return ret
}
// Logs returns existing logs.
func (l *logger) Logs() []CheckDetail {
return l.logs
}
// Copyright 2020 OpenSSF 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 checker
import (
"fmt"
"time"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
)
// RawResults contains results before a policy
// is applied.
//
//nolint:govet
type RawResults struct {
BinaryArtifactResults BinaryArtifactData
BranchProtectionResults BranchProtectionsData
CIIBestPracticesResults CIIBestPracticesData
CITestResults CITestData
CodeReviewResults CodeReviewData
ContributorsResults ContributorsData
DangerousWorkflowResults DangerousWorkflowData
DependencyUpdateToolResults DependencyUpdateToolData
FuzzingResults FuzzingData
LicenseResults LicenseData
SBOMResults SBOMData
MaintainedResults MaintainedData
Metadata MetadataData
PackagingResults PackagingData
PinningDependenciesResults PinningDependenciesData
SASTResults SASTData
SecurityPolicyResults SecurityPolicyData
SignedReleasesResults SignedReleasesData
TokenPermissionsResults TokenPermissionsData
VulnerabilitiesResults VulnerabilitiesData
WebhookResults WebhooksData
}
type MetadataData struct {
Metadata map[string]string
}
type RevisionCIInfo struct {
HeadSHA string
CheckRuns []clients.CheckRun
Statuses []clients.Status
PullRequestNumber int
}
type CITestData struct {
CIInfo []RevisionCIInfo
}
// FuzzingData represents different fuzzing done.
type FuzzingData struct {
Fuzzers []Tool
}
// TODO: Add Msg to all results.
// PackagingData contains results for the Packaging check.
type PackagingData struct {
Packages []Package
}
// Package represents a package.
type Package struct {
// TODO: not supported yet. This needs to be unique across
// ecosystems: purl, OSV, CPE, etc.
Name *string
Job *WorkflowJob
File *File
// Note: Msg is populated only for debug messages.
Msg *string
Runs []Run
}
type PackageProvenance struct {
Commit string
IsVerified bool
}
type ProjectPackage struct {
System string
Name string
Version string
Provenance PackageProvenance
}
// DependencyUseType represents a type of dependency use.
type DependencyUseType string
const (
// DependencyUseTypeGHAction is an action.
DependencyUseTypeGHAction DependencyUseType = "GitHubAction"
// DependencyUseTypeDockerfileContainerImage a container image used via FROM.
DependencyUseTypeDockerfileContainerImage DependencyUseType = "containerImage"
// DependencyUseTypeDownloadThenRun is a download followed by a run.
DependencyUseTypeDownloadThenRun DependencyUseType = "downloadThenRun"
// DependencyUseTypeGoCommand is a go command.
DependencyUseTypeGoCommand DependencyUseType = "goCommand"
// DependencyUseTypeChocoCommand is a choco command.
DependencyUseTypeChocoCommand DependencyUseType = "chocoCommand"
// DependencyUseTypeNpmCommand is an npm command.
DependencyUseTypeNpmCommand DependencyUseType = "npmCommand"
// DependencyUseTypePipCommand is a pip command.
DependencyUseTypePipCommand DependencyUseType = "pipCommand"
// DependencyUseTypeNugetCommand is a nuget command.
DependencyUseTypeNugetCommand DependencyUseType = "nugetCommand"
)
// PinningDependenciesData represents pinned dependency data.
type PinningDependenciesData struct {
Dependencies []Dependency
ProcessingErrors []ElementError // jobs or files with errors may have incomplete results
}
// Dependency represents a dependency.
type Dependency struct {
// TODO: unique dependency name.
// TODO: Job *WorkflowJob
Name *string
PinnedAt *string
Location *File
Msg *string // Only for debug messages.
Pinned *bool
Remediation *finding.Remediation
Type DependencyUseType
}
// MaintainedData contains the raw results
// for the Maintained check.
type MaintainedData struct {
CreatedAt time.Time
Issues []clients.Issue
DefaultBranchCommits []clients.Commit
ArchivedStatus ArchivedStatus
}
type LicenseAttributionType string
const (
// sources of license information used to assert repo's license.
LicenseAttributionTypeOther LicenseAttributionType = "other"
LicenseAttributionTypeAPI LicenseAttributionType = "repositoryAPI"
LicenseAttributionTypeHeuristics LicenseAttributionType = "builtinHeuristics"
)
// license details.
type License struct {
Name string // OSI standardized license name
SpdxID string // SPDX standardized identifier
Attribution LicenseAttributionType // source of licensing information
Approved bool // FSF or OSI Approved License
}
// one file contains one license.
type LicenseFile struct {
LicenseInformation License
File File
}
// LicenseData contains the raw results
// for the License check.
// Some repos may have more than one license.
type LicenseData struct {
LicenseFiles []LicenseFile
}
// SBOM details.
type SBOM struct {
Name string // SBOM Filename
File File // SBOM File Object
}
// SBOMData contains the raw results for the SBOM check.
// Some repos may have more than one SBOM.
type SBOMData struct {
SBOMFiles []SBOM
}
// CodeReviewData contains the raw results
// for the Code-Review check.
type CodeReviewData struct {
DefaultBranchChangesets []Changeset
}
type ReviewPlatform = string
const (
ReviewPlatformGitHub ReviewPlatform = "GitHub"
ReviewPlatformProw ReviewPlatform = "Prow"
ReviewPlatformGerrit ReviewPlatform = "Gerrit"
ReviewPlatformPhabricator ReviewPlatform = "Phabricator"
ReviewPlatformPiper ReviewPlatform = "Piper"
ReviewPlatformUnknown ReviewPlatform = "Unknown"
)
type Changeset struct {
ReviewPlatform string
RevisionID string
Commits []clients.Commit
Reviews []clients.Review
Author clients.User
}
// ContributorsData represents contributor information.
type ContributorsData struct {
Users []clients.User
}
// VulnerabilitiesData contains the raw results
// for the Vulnerabilities check.
type VulnerabilitiesData struct {
Vulnerabilities []clients.Vulnerability
}
type SecurityPolicyInformationType string
const (
// forms of security policy hints being evaluated.
SecurityPolicyInformationTypeEmail SecurityPolicyInformationType = "emailAddress"
SecurityPolicyInformationTypeLink SecurityPolicyInformationType = "httpLink"
SecurityPolicyInformationTypeText SecurityPolicyInformationType = "vulnDisclosureText"
)
type SecurityPolicyValueType struct {
Match string // Snippet of match
LineNumber uint // Line number in policy file of match
Offset uint // Offset in the line of the match
}
type SecurityPolicyInformation struct {
InformationType SecurityPolicyInformationType
InformationValue SecurityPolicyValueType
}
type SecurityPolicyFile struct {
// security policy information found in repo or org
Information []SecurityPolicyInformation
// file that contains the security policy information
File File
}
// SASTData contains the raw results
// for the SAST check.
type SASTData struct {
Workflows []SASTWorkflow
Commits []SASTCommit
NumWorkflows int
}
type SASTCommit struct {
CommittedDate time.Time
Message string
SHA string
CheckRuns []clients.CheckRun
AssociatedMergeRequest clients.PullRequest
Committer clients.User
Compliant bool
}
// SASTWorkflowType represents a type of SAST workflow.
type SASTWorkflowType string
const (
// CodeQLWorkflow represents a workflow that runs CodeQL.
CodeQLWorkflow SASTWorkflowType = "CodeQL"
// SonarWorkflow represents a workflow that runs Sonar.
SonarWorkflow SASTWorkflowType = "Sonar"
// SnykWorkflow represents a workflow that runs Snyk.
SnykWorkflow SASTWorkflowType = "Snyk"
// PysaWorkflow represents a workflow that runs Pysa.
PysaWorkflow SASTWorkflowType = "Pysa"
// QodanaWorkflow represents a workflow that runs Qodana.
QodanaWorkflow SASTWorkflowType = "Qodana"
)
// SASTWorkflow represents a SAST workflow.
type SASTWorkflow struct {
Type SASTWorkflowType
File File
}
// SecurityPolicyData contains the raw results
// for the Security-Policy check.
type SecurityPolicyData struct {
PolicyFiles []SecurityPolicyFile
}
// BinaryArtifactData contains the raw results
// for the Binary-Artifact check.
type BinaryArtifactData struct {
// Files contains a list of files.
Files []File
}
// SignedReleasesData contains the raw results
// for the Signed-Releases check.
type SignedReleasesData struct {
Releases []clients.Release
Packages []ProjectPackage
}
// DependencyUpdateToolData contains the raw results
// for the Dependency-Update-Tool check.
type DependencyUpdateToolData struct {
// Tools contains a list of tools.
Tools []Tool
}
// WebhooksData contains the raw results
// for the Webhook check.
type WebhooksData struct {
Webhooks []clients.Webhook
}
// BranchProtectionsData contains the raw results
// for the Branch-Protection check.
type BranchProtectionsData struct {
Branches []clients.BranchRef
CodeownersFiles []string
}
// Tool represents a tool.
type Tool struct {
URL *string
Desc *string
Files []File
Name string
// Runs of the tool.
Runs []Run
// Issues created by the tool.
Issues []clients.Issue
// Merge requests created by the tool.
MergeRequests []clients.PullRequest
// TODO: CodeCoverage, jsonWorkflowJob.
}
// Run represents a run.
type Run struct {
URL string
}
// ArchivedStatus defines the archived status.
type ArchivedStatus struct {
Status bool
// TODO: add fields, e.g., date of archival.
}
// File represents a file.
type File struct {
Path string
Snippet string // Snippet of code
Offset uint // Offset in the file of Path (line for source/text files).
EndOffset uint // End of offset in the file, e.g. if the command spans multiple lines.
FileSize uint // Total size of file.
Type finding.FileType // Type of file.
// TODO: add hash.
}
// CIIBestPracticesData contains data for CIIBestPractices check.
type CIIBestPracticesData struct {
Badge clients.BadgeLevel
}
// DangerousWorkflowType represents a type of dangerous workflow.
type DangerousWorkflowType string
const (
// DangerousWorkflowScriptInjection represents a script injection.
DangerousWorkflowScriptInjection DangerousWorkflowType = "scriptInjection"
// DangerousWorkflowUntrustedCheckout represents an untrusted checkout.
DangerousWorkflowUntrustedCheckout DangerousWorkflowType = "untrustedCheckout"
)
// DangerousWorkflowData contains raw results
// for dangerous workflow check.
type DangerousWorkflowData struct {
Workflows []DangerousWorkflow
NumWorkflows int
}
// DangerousWorkflow represents a dangerous workflow.
type DangerousWorkflow struct {
Job *WorkflowJob
Type DangerousWorkflowType
File File
}
// WorkflowJob represents a workflow job.
type WorkflowJob struct {
Name *string
ID *string
}
// TokenPermissionsData represents data about a permission failure.
type TokenPermissionsData struct {
TokenPermissions []TokenPermission
NumTokens int
}
// PermissionLocation represents a declaration type.
type PermissionLocation string
const (
// PermissionLocationTop is top-level workflow permission.
PermissionLocationTop PermissionLocation = "topLevel"
// PermissionLocationJob is job-level workflow permission.
PermissionLocationJob PermissionLocation = "jobLevel"
)
// PermissionLevel represents a permission type.
type PermissionLevel string
const (
// PermissionLevelUndeclared is an undeclared permission.
PermissionLevelUndeclared PermissionLevel = "undeclared"
// PermissionLevelWrite is a permission set to `write` for a permission we consider potentially dangerous.
PermissionLevelWrite PermissionLevel = "write"
// PermissionLevelRead is a permission set to `read`.
PermissionLevelRead PermissionLevel = "read"
// PermissionLevelNone is a permission set to `none`.
PermissionLevelNone PermissionLevel = "none"
// PermissionLevelUnknown is for other kinds of alerts, mostly to support debug messages.
// TODO: remove it once we have implemented severity (#1874).
PermissionLevelUnknown PermissionLevel = "unknown"
)
// TokenPermission defines a token permission result.
type TokenPermission struct {
Job *WorkflowJob
LocationType *PermissionLocation
Name *string
Value *string
File *File
Msg *string
Type PermissionLevel
}
// Location generates location from a file.
func (f *File) Location() *finding.Location {
// TODO(2626): merge location and path.
if f == nil {
return nil
}
loc := &finding.Location{
Type: f.Type,
Path: f.Path,
LineStart: &f.Offset,
}
if f.EndOffset != 0 {
loc.LineEnd = &f.EndOffset
}
if f.Snippet != "" {
loc.Snippet = &f.Snippet
}
return loc
}
// ElementError allows us to identify the "element" that led to the given error.
// The "element" is the specific "code under analysis" that caused the error. It should
// describe what caused the error as precisely as possible.
//
// For example, if a shell parsing error occurs while parsing a Dockerfile `RUN` block
// or a GitHub workflow's `run:` step, the "element" should point to the Dockerfile
// lines or workflow job step that caused the failure, not just the file path.
type ElementError struct {
Err error
Location finding.Location
}
func (e *ElementError) Error() string {
return fmt.Sprintf("%s: %v", e.Err, e.Location)
}
func (e *ElementError) Unwrap() error {
return e.Err
}
// Copyright 2020 OpenSSF 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 checks defines all Scorecard checks.
package checks
import (
"os"
"github.com/ossf/scorecard/v5/checker"
)
// allChecks is the list of all registered security checks.
var allChecks = checker.CheckNameToFnMap{}
func getAll(overrideExperimental bool) checker.CheckNameToFnMap {
// need to make a copy or caller could mutate original map
possibleChecks := checker.CheckNameToFnMap{}
for k, v := range allChecks {
possibleChecks[k] = v
}
if overrideExperimental {
return possibleChecks
}
if _, experimental := os.LookupEnv("SCORECARD_EXPERIMENTAL"); !experimental {
// TODO: remove this check when v6 is released
delete(possibleChecks, CheckWebHooks)
delete(possibleChecks, CheckSBOM)
}
return possibleChecks
}
// GetAll returns the full list of default checks, excluding any experimental checks
// unless environment variable constraints are satisfied.
func GetAll() checker.CheckNameToFnMap {
return getAll(false /*overrideExperimental*/)
}
// GetAllWithExperimental returns the full list of checks, including experimental checks.
func GetAllWithExperimental() checker.CheckNameToFnMap {
return getAll(true /*overrideExperimental*/)
}
func registerCheck(name string, fn checker.CheckFn, supportedRequestTypes []checker.RequestType) error {
if name == "" {
return errInternalNameCannotBeEmpty
}
if fn == nil {
return errInternalCheckFuncCannotBeNil
}
allChecks[name] = checker.Check{
Fn: fn,
SupportedRequestTypes: supportedRequestTypes,
}
return nil
}
// Copyright 2021 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckBinaryArtifacts is the exported name for Binary-Artifacts check.
const CheckBinaryArtifacts string = "Binary-Artifacts"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckBinaryArtifacts, BinaryArtifacts, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// BinaryArtifacts will check the repository contains binary artifacts.
func BinaryArtifacts(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.BinaryArtifacts(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBinaryArtifacts, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.BinaryArtifactResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.BinaryArtifacts)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBinaryArtifacts, e)
}
ret := evaluation.BinaryArtifacts(CheckBinaryArtifacts, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckBranchProtection is the exported name for Branch-Protected check.
const CheckBranchProtection = "Branch-Protection"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckBranchProtection, BranchProtection, nil); err != nil {
// this should never happen
panic(err)
}
}
// BranchProtection runs the Branch-Protection check.
func BranchProtection(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.BranchProtection(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBranchProtection, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.BranchProtectionResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.BranchProtection)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBranchProtection, e)
}
// Return the score evaluation.
ret := evaluation.BranchProtection(CheckBranchProtection, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
const CheckCITests = "CI-Tests"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
}
if err := registerCheck(CheckCITests, CITests, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
func CITests(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.CITests(c.RepoClient)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCITests, e)
}
pRawResults := getRawResults(c)
pRawResults.CITestResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.CITests)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCITests, e)
}
ret := evaluation.CITests(CheckCITests, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckCIIBestPractices is the registered name for CIIBestPractices.
const CheckCIIBestPractices = "CII-Best-Practices"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckCIIBestPractices, CIIBestPractices, nil); err != nil {
// this should never happen
panic(err)
}
}
// CIIBestPractices will check if the maintainers have a best practice badge.
func CIIBestPractices(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.CIIBestPractices(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCIIBestPractices, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.CIIBestPracticesResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.CIIBestPractices)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCIIBestPractices, e)
}
// Return the score evaluation.
ret := evaluation.CIIBestPractices(CheckCIIBestPractices, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2023 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckCodeReview is the registered name for DoesCodeReview.
const CheckCodeReview = "Code-Review"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
}
if err := registerCheck(CheckCodeReview, CodeReview, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// CodeReview will check if the maintainers perform code review.
func CodeReview(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.CodeReview(c.RepoClient)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCodeReview, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.CodeReviewResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.CodeReview)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckCodeReview, e)
}
// Return the score evaluation.
ret := evaluation.CodeReview(CheckCodeReview, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckContributors is the registered name for Contributors.
const CheckContributors = "Contributors"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckContributors, Contributors, nil); err != nil {
// this should never happen
panic(err)
}
}
// Contributors run Contributors check.
func Contributors(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.Contributors(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckContributors, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.ContributorsResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.Contributors)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckContributors, e)
}
// Return the score evaluation.
ret := evaluation.Contributors(CheckContributors, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2021 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckDangerousWorkflow is the exported name for Dangerous-Workflow check.
const CheckDangerousWorkflow = "Dangerous-Workflow"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckDangerousWorkflow, DangerousWorkflow, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// DangerousWorkflow will check the repository contains Dangerous-Workflow.
func DangerousWorkflow(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.DangerousWorkflow(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckDangerousWorkflow, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.DangerousWorkflowResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.DangerousWorkflows)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckDangerousWorkflow, e)
}
ret := evaluation.DangerousWorkflow(CheckDangerousWorkflow, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckDependencyUpdateTool is the exported name for Dependency-Update-Tool.
const CheckDependencyUpdateTool = "Dependency-Update-Tool"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.FileBased,
}
if err := registerCheck(CheckDependencyUpdateTool, DependencyUpdateTool, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// DependencyUpdateTool checks if the repository uses a dependency update tool.
func DependencyUpdateTool(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.DependencyUpdateTool(c.RepoClient)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckDependencyUpdateTool, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.DependencyUpdateToolResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.DependencyToolUpdates)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckDependencyUpdateTool, e)
}
// Return the score evaluation.
ret := evaluation.DependencyUpdateTool(CheckDependencyUpdateTool, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts"
)
// BinaryArtifacts applies the score policy for the Binary-Artifacts check.
func BinaryArtifacts(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasUnverifiedBinaryArtifacts.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
if findings[0].Outcome == finding.OutcomeFalse {
return checker.CreateMaxScoreResult(name, "no binaries found in the repo")
}
for i := range findings {
f := &findings[i]
if f.Outcome != finding.OutcomeTrue {
continue
}
dl.Warn(&checker.LogMessage{
Path: f.Location.Path,
Type: f.Location.Type,
Offset: *f.Location.LineStart,
Text: "binary detected",
})
}
// There are only false findings.
// Deduct the number of findings from max score
numberOfBinaryFilesFound := len(findings)
score := checker.MaxResultScore - numberOfBinaryFilesFound
if score < checker.MinResultScore {
score = checker.MinResultScore
}
return checker.CreateResultWithScore(name, "binaries present in source code", score)
}
// Copyright 2020 OpenSSF 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 evaluation
import (
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/blocksDeleteOnBranches"
"github.com/ossf/scorecard/v5/probes/blocksForcePushOnBranches"
"github.com/ossf/scorecard/v5/probes/branchProtectionAppliesToAdmins"
"github.com/ossf/scorecard/v5/probes/branchesAreProtected"
"github.com/ossf/scorecard/v5/probes/dismissesStaleReviews"
"github.com/ossf/scorecard/v5/probes/requiresApproversForPullRequests"
"github.com/ossf/scorecard/v5/probes/requiresCodeOwnersReview"
"github.com/ossf/scorecard/v5/probes/requiresLastPushApproval"
"github.com/ossf/scorecard/v5/probes/requiresPRsToChangeCode"
"github.com/ossf/scorecard/v5/probes/requiresUpToDateBranches"
"github.com/ossf/scorecard/v5/probes/runsStatusChecksBeforeMerging"
)
const (
minReviews = 2
// Points incremented at each level.
basicLevel = 3 // Level 1.
adminNonAdminReviewLevel = 3 // Level 2.
nonAdminContextLevel = 2 // Level 3.
nonAdminThoroughReviewLevel = 1 // Level 4.
adminThoroughReviewLevel = 1 // Level 5.
)
type scoresInfo struct {
basic int
review int
adminReview int
context int
thoroughReview int
adminThoroughReview int
codeownerReview int
}
// Maximum score depending on whether admin token is used.
type levelScore struct {
scores scoresInfo // Score result for a branch.
maxes scoresInfo // Maximum possible score for a branch.
}
type tier uint8
const (
Tier1 tier = iota
Tier2
Tier3
Tier4
Tier5
)
// BranchProtection runs Branch-Protection check.
func BranchProtection(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
blocksDeleteOnBranches.Probe,
blocksForcePushOnBranches.Probe,
branchesAreProtected.Probe,
branchProtectionAppliesToAdmins.Probe,
dismissesStaleReviews.Probe,
requiresApproversForPullRequests.Probe,
requiresCodeOwnersReview.Probe,
requiresLastPushApproval.Probe,
requiresUpToDateBranches.Probe,
runsStatusChecksBeforeMerging.Probe,
requiresPRsToChangeCode.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Create a map branches and whether theyare protected
// Protected field only indates that the branch matches
// one `Branch protection rules`. All settings may be disabled,
// so it does not provide any guarantees.
protectedBranches := make(map[string]bool)
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeNotApplicable {
return checker.CreateInconclusiveResult(name,
"unable to detect any development/release branches")
}
branchName, err := getBranchName(f)
if err != nil {
return checker.CreateRuntimeErrorResult(name, err)
}
// the order of this switch statement matters.
switch {
// Sanity check:
case f.Probe != branchesAreProtected.Probe:
continue
// Sanity check:
case branchName == "":
e := sce.WithMessage(sce.ErrScorecardInternal, "probe is missing branch name")
return checker.CreateRuntimeErrorResult(name, e)
// Now we can check whether the branch is protected:
case f.Outcome == finding.OutcomeFalse:
protectedBranches[branchName] = false
dl.Warn(&checker.LogMessage{
Text: fmt.Sprintf("branch protection not enabled for branch '%s'", branchName),
})
case f.Outcome == finding.OutcomeTrue:
protectedBranches[branchName] = true
default:
continue
}
}
branchScores := make(map[string]*levelScore)
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeNotApplicable {
return checker.CreateInconclusiveResult(name, "unable to detect any development/release branches")
}
branchName, err := getBranchName(f)
if err != nil {
return checker.CreateRuntimeErrorResult(name, err)
}
if branchName == "" {
e := sce.WithMessage(sce.ErrScorecardInternal, "probe is missing branch name")
return checker.CreateRuntimeErrorResult(name, e)
}
if _, ok := branchScores[branchName]; !ok {
branchScores[branchName] = &levelScore{}
}
var score, maxScore int
doLogging := protectedBranches[branchName]
switch f.Probe {
case blocksDeleteOnBranches.Probe, blocksForcePushOnBranches.Probe:
score, maxScore = deleteAndForcePushProtection(f, doLogging, dl)
branchScores[branchName].scores.basic += score
branchScores[branchName].maxes.basic += maxScore
case dismissesStaleReviews.Probe, branchProtectionAppliesToAdmins.Probe:
score, maxScore = adminThoroughReviewProtection(f, doLogging, dl)
branchScores[branchName].scores.adminThoroughReview += score
branchScores[branchName].maxes.adminThoroughReview += maxScore
case requiresApproversForPullRequests.Probe:
noOfRequiredReviewers, err := getReviewerCount(f)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, "unable to get reviewer count")
return checker.CreateRuntimeErrorResult(name, e)
}
// Scorecard evaluation scores twice with this probe:
// Once if the count is above 0
// Once if the count is above 2
score, maxScore = logReviewerCount(f, doLogging, dl, noOfRequiredReviewers)
branchScores[branchName].scores.thoroughReview += score
branchScores[branchName].maxes.thoroughReview += maxScore
reviewerWeight := 2
maxScore = reviewerWeight
if f.Outcome == finding.OutcomeTrue && noOfRequiredReviewers > 0 {
branchScores[branchName].scores.review += reviewerWeight
}
branchScores[branchName].maxes.review += maxScore
case requiresCodeOwnersReview.Probe:
score, maxScore = codeownerBranchProtection(f, doLogging, dl)
branchScores[branchName].scores.codeownerReview += score
branchScores[branchName].maxes.codeownerReview += maxScore
case requiresUpToDateBranches.Probe, requiresLastPushApproval.Probe,
requiresPRsToChangeCode.Probe:
score, maxScore = adminReviewProtection(f, doLogging, dl)
branchScores[branchName].scores.adminReview += score
branchScores[branchName].maxes.adminReview += maxScore
case runsStatusChecksBeforeMerging.Probe:
score, maxScore = nonAdminContextProtection(f, doLogging, dl)
branchScores[branchName].scores.context += score
branchScores[branchName].maxes.context += maxScore
}
}
if len(branchScores) == 0 {
return checker.CreateInconclusiveResult(name, "unable to detect any development/release branches")
}
scores := make([]levelScore, 0, len(branchScores))
for _, v := range branchScores {
scores = append(scores, *v)
}
score, err := computeFinalScore(scores)
if err != nil {
return checker.CreateRuntimeErrorResult(name, err)
}
switch score {
case checker.MinResultScore:
return checker.CreateMinScoreResult(name,
"branch protection not enabled on development/release branches")
case checker.MaxResultScore:
return checker.CreateMaxScoreResult(name,
"branch protection is fully enabled on development and all release branches")
default:
return checker.CreateResultWithScore(name,
"branch protection is not maximal on development and all release branches", score)
}
}
func getBranchName(f *finding.Finding) (string, error) {
name, ok := f.Values["branchName"]
if !ok {
return "", sce.WithMessage(sce.ErrScorecardInternal, "no branch name found")
}
return name, nil
}
func getReviewerCount(f *finding.Finding) (int, error) {
// assume no review required if data not available
if f.Outcome == finding.OutcomeNotAvailable {
return 0, nil
}
countString, ok := f.Values[requiresApproversForPullRequests.RequiredReviewersKey]
if !ok {
return 0, sce.WithMessage(sce.ErrScorecardInternal, "no required reviewer count found")
}
count, err := strconv.Atoi(countString)
if err != nil {
return 0, sce.WithMessage(sce.ErrScorecardInternal, "unable to parse required reviewer count")
}
return count, nil
}
func sumUpScoreForTier(t tier, scoresData []levelScore) int {
sum := 0
for i := range scoresData {
score := scoresData[i]
switch t {
case Tier1:
sum += score.scores.basic
case Tier2:
sum += score.scores.review + score.scores.adminReview
case Tier3:
sum += score.scores.context
case Tier4:
sum += score.scores.thoroughReview + score.scores.codeownerReview
case Tier5:
sum += score.scores.adminThoroughReview
}
}
return sum
}
func logWithDebug(f *finding.Finding, doLogging bool, dl checker.DetailLogger) {
switch f.Outcome {
case finding.OutcomeNotAvailable:
debug(dl, doLogging, f.Message)
case finding.OutcomeTrue:
info(dl, doLogging, f.Message)
case finding.OutcomeFalse:
warn(dl, doLogging, f.Message)
default:
// To satisfy linter
}
}
func logWithoutDebug(f *finding.Finding, doLogging bool, dl checker.DetailLogger) {
switch f.Outcome {
case finding.OutcomeTrue:
info(dl, doLogging, f.Message)
case finding.OutcomeFalse:
warn(dl, doLogging, f.Message)
default:
// To satisfy linter
}
}
func logInfoOrWarn(f *finding.Finding, doLogging bool, dl checker.DetailLogger) {
switch f.Outcome {
case finding.OutcomeTrue:
info(dl, doLogging, f.Message)
default:
warn(dl, doLogging, f.Message)
}
}
func normalizeScore(score, maxScore, level int) float64 {
if maxScore == 0 {
return float64(level)
}
return float64(score*level) / float64(maxScore)
}
func computeFinalScore(scores []levelScore) (int, error) {
if len(scores) == 0 {
return 0, sce.WithMessage(sce.ErrScorecardInternal, "scores are empty")
}
score := float64(0)
maxScore := scores[0].maxes
// First, check if they all pass the basic (admin and non-admin) checks.
maxBasicScore := maxScore.basic * len(scores)
basicScore := sumUpScoreForTier(Tier1, scores)
score += normalizeScore(basicScore, maxBasicScore, basicLevel)
if basicScore < maxBasicScore {
return int(score), nil
}
// Second, check the (admin and non-admin) reviews.
maxReviewScore := maxScore.review * len(scores)
maxAdminReviewScore := maxScore.adminReview * len(scores)
adminNonAdminReviewScore := sumUpScoreForTier(Tier2, scores)
score += normalizeScore(adminNonAdminReviewScore, maxReviewScore+maxAdminReviewScore, adminNonAdminReviewLevel)
if adminNonAdminReviewScore < maxReviewScore+maxAdminReviewScore {
return int(score), nil
}
// Third, check the use of non-admin context.
maxContextScore := maxScore.context * len(scores)
contextScore := sumUpScoreForTier(Tier3, scores)
score += normalizeScore(contextScore, maxContextScore, nonAdminContextLevel)
if contextScore < maxContextScore {
return int(score), nil
}
// Fourth, check the thorough non-admin reviews.
// Also check whether this repo requires codeowner review
maxThoroughReviewScore := maxScore.thoroughReview * len(scores)
maxCodeownerReviewScore := maxScore.codeownerReview * len(scores)
tier4Score := sumUpScoreForTier(Tier4, scores)
score += normalizeScore(tier4Score, maxThoroughReviewScore+maxCodeownerReviewScore, nonAdminThoroughReviewLevel)
if tier4Score < maxThoroughReviewScore+maxCodeownerReviewScore {
return int(score), nil
}
// Lastly, check the thorough admin review config.
// This one is controversial and has usability issues
// https://github.com/ossf/scorecard/issues/1027, so we may remove it.
maxAdminThoroughReviewScore := maxScore.adminThoroughReview * len(scores)
adminThoroughReviewScore := sumUpScoreForTier(Tier5, scores)
score += normalizeScore(adminThoroughReviewScore, maxAdminThoroughReviewScore, adminThoroughReviewLevel)
if adminThoroughReviewScore != maxAdminThoroughReviewScore {
return int(score), nil
}
return int(score), nil
}
func info(dl checker.DetailLogger, doLogging bool, msg string) {
if !doLogging {
return
}
dl.Info(&checker.LogMessage{
Text: msg,
})
}
func debug(dl checker.DetailLogger, doLogging bool, msg string) {
if !doLogging {
return
}
dl.Debug(&checker.LogMessage{
Text: msg,
})
}
func warn(dl checker.DetailLogger, doLogging bool, msg string) {
if !doLogging {
return
}
dl.Warn(&checker.LogMessage{
Text: msg,
})
}
func deleteAndForcePushProtection(f *finding.Finding, doLogging bool, dl checker.DetailLogger) (int, int) {
var score, maxScore int
logWithoutDebug(f, doLogging, dl)
if f.Outcome == finding.OutcomeTrue {
score++
}
maxScore++
return score, maxScore
}
func nonAdminContextProtection(f *finding.Finding, doLogging bool, dl checker.DetailLogger) (int, int) {
var score, maxScore int
logInfoOrWarn(f, doLogging, dl)
if f.Outcome == finding.OutcomeTrue {
score++
}
maxScore++
return score, maxScore
}
func adminReviewProtection(f *finding.Finding, doLogging bool, dl checker.DetailLogger) (int, int) {
var score, maxScore int
if f.Outcome == finding.OutcomeTrue {
score++
}
logWithDebug(f, doLogging, dl)
if f.Outcome != finding.OutcomeNotAvailable {
maxScore++
}
return score, maxScore
}
func adminThoroughReviewProtection(f *finding.Finding, doLogging bool, dl checker.DetailLogger) (int, int) {
var score, maxScore int
logWithDebug(f, doLogging, dl)
if f.Outcome == finding.OutcomeTrue {
score++
}
if f.Outcome != finding.OutcomeNotAvailable {
maxScore++
}
return score, maxScore
}
func logReviewerCount(f *finding.Finding, doLogging bool, dl checker.DetailLogger, noOfRequiredReviews int) (int, int) {
var score, maxScore int
if f.Outcome == finding.OutcomeTrue {
if noOfRequiredReviews >= minReviews {
info(dl, doLogging, f.Message)
score++
} else {
warn(dl, doLogging, f.Message)
}
} else if f.Outcome == finding.OutcomeFalse {
warn(dl, doLogging, f.Message)
}
maxScore++
return score, maxScore
}
func codeownerBranchProtection(f *finding.Finding, doLogging bool, dl checker.DetailLogger) (int, int) {
var score, maxScore int
if f.Outcome == finding.OutcomeTrue {
info(dl, doLogging, f.Message)
score++
} else {
warn(dl, doLogging, f.Message)
}
maxScore++
return score, maxScore
}
// Copyright 2022 OpenSSF 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 evaluation
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/testsRunInCI"
)
const CheckCITests = "CI-Tests"
func CITests(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
testsRunInCI.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Debug PRs that were merged without CI tests
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeFalse || f.Outcome == finding.OutcomeTrue {
dl.Debug(&checker.LogMessage{
Text: f.Message,
})
}
}
// check that the project has pull requests
if noPullRequestsFound(findings) {
return checker.CreateInconclusiveResult(CheckCITests, "no pull request found")
}
totalMerged, totalTested := getMergedAndTested(findings)
if totalMerged < totalTested || len(findings) < totalTested {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid finding values")
return checker.CreateRuntimeErrorResult(name, e)
}
reason := fmt.Sprintf("%d out of %d merged PRs checked by a CI test", totalTested, totalMerged)
return checker.CreateProportionalScoreResult(CheckCITests, reason, totalTested, totalMerged)
}
func getMergedAndTested(findings []finding.Finding) (int, int) {
totalMerged := 0
totalTested := 0
for i := range findings {
f := &findings[i]
totalMerged++
if f.Outcome == finding.OutcomeTrue {
totalTested++
}
}
return totalMerged, totalTested
}
func noPullRequestsFound(findings []finding.Finding) bool {
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeNotApplicable {
return true
}
}
return false
}
// Copyright 2022 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasOpenSSFBadge"
)
const (
silverScore = 7
// Note: if this value is changed, please update the action's threshold score
// https://github.com/ossf/scorecard-action/blob/main/policies/template.yml#L61.
passingScore = 5
inProgressScore = 2
)
// CIIBestPractices applies the score policy for the CIIBestPractices check.
func CIIBestPractices(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasOpenSSFBadge.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var score int
var text string
if len(findings) != 1 {
errText := "invalid probe results: multiple findings detected"
e := sce.WithMessage(sce.ErrScorecardInternal, errText)
return checker.CreateRuntimeErrorResult(name, e)
}
f := &findings[0]
if f.Outcome == finding.OutcomeFalse {
text = "no effort to earn an OpenSSF best practices badge detected"
return checker.CreateMinScoreResult(name, text)
}
level, ok := f.Values[hasOpenSSFBadge.LevelKey]
if !ok {
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, "no badge level present"))
}
switch level {
case hasOpenSSFBadge.GoldLevel:
score = checker.MaxResultScore
text = "badge detected: Gold"
case hasOpenSSFBadge.SilverLevel:
score = silverScore
text = "badge detected: Silver"
case hasOpenSSFBadge.PassingLevel:
score = passingScore
text = "badge detected: Passing"
case hasOpenSSFBadge.InProgressLevel:
score = inProgressScore
text = "badge detected: InProgress"
default:
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, "unsupported badge detected"))
}
return checker.CreateResultWithScore(name, text, score)
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"strconv"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/codeApproved"
)
// TODO(raghavkaul) More partial credit? E.g. approval from non-contributor, discussion liveness,
// number of resolved comments, number of approvers (more eyes on a project).
// CodeReview applies the score policy for the Code-Review check.
func CodeReview(name string, findings []finding.Finding, dl checker.DetailLogger) checker.CheckResult {
expectedProbes := []string{
codeApproved.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
for _, f := range findings {
switch f.Outcome {
case finding.OutcomeNotApplicable:
return checker.CreateInconclusiveResult(name, f.Message)
case finding.OutcomeTrue:
return checker.CreateMaxScoreResult(name, "all changesets reviewed")
case finding.OutcomeError:
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, f.Message))
default:
approved, err := strconv.Atoi(f.Values[codeApproved.NumApprovedKey])
if err != nil {
err = sce.WithMessage(sce.ErrScorecardInternal, "converting approved count: %v")
return checker.CreateRuntimeErrorResult(name, err)
}
total, err := strconv.Atoi(f.Values[codeApproved.NumTotalKey])
if err != nil {
err = sce.WithMessage(sce.ErrScorecardInternal, "converting total count: %v")
return checker.CreateRuntimeErrorResult(name, err)
}
return checker.CreateProportionalScoreResult(name, f.Message, approved, total)
}
}
return checker.CreateMaxScoreResult(name, "all changesets reviewed")
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"fmt"
"slices"
"strings"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/contributorsFromOrgOrCompany"
)
const (
numberCompaniesForTopScore = 3
)
// Contributors applies the score policy for the Contributors check.
func Contributors(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
contributorsFromOrgOrCompany.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
numberOfTrue := getNumberOfTrue(findings)
reason := fmt.Sprintf("project has %d contributing companies or organizations", numberOfTrue)
if numberOfTrue > 0 {
logFindings(findings, dl)
}
if numberOfTrue > numberCompaniesForTopScore {
return checker.CreateMaxScoreResult(name, reason)
}
return checker.CreateProportionalScoreResult(name, reason, numberOfTrue, numberCompaniesForTopScore)
}
func getNumberOfTrue(findings []finding.Finding) int {
var numberOfTrue int
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeTrue {
if f.Probe == contributorsFromOrgOrCompany.Probe {
numberOfTrue++
}
}
}
return numberOfTrue
}
func logFindings(findings []finding.Finding, dl checker.DetailLogger) {
var orgs []string
const suffix = " contributor org/company found"
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeTrue {
org := strings.TrimSuffix(f.Message, suffix)
orgs = append(orgs, org)
}
}
slices.Sort(orgs)
dl.Info(&checker.LogMessage{
Text: "found contributions from: " + strings.Join(orgs, ", "),
})
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowScriptInjection"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowUntrustedCheckout"
)
// DangerousWorkflow applies the score policy for the DangerousWorkflow check.
func DangerousWorkflow(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasDangerousWorkflowScriptInjection.Probe,
hasDangerousWorkflowUntrustedCheckout.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
if !hasWorkflows(findings) {
return checker.CreateInconclusiveResult(name, "no workflows found")
}
// Log all detected dangerous workflows
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeTrue {
if f.Location == nil {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
checker.LogFinding(dl, f, checker.DetailWarn)
}
}
if hasDWWithUntrustedCheckout(findings) || hasDWWithScriptInjection(findings) {
return checker.CreateMinScoreResult(name,
"dangerous workflow patterns detected")
}
return checker.CreateMaxScoreResult(name,
"no dangerous workflow patterns detected")
}
// Both probes return OutcomeNotApplicable, if there project has no workflows.
func hasWorkflows(findings []finding.Finding) bool {
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeNotApplicable {
return false
}
}
return true
}
func hasDWWithUntrustedCheckout(findings []finding.Finding) bool {
for i := range findings {
f := &findings[i]
if f.Probe == hasDangerousWorkflowUntrustedCheckout.Probe {
if f.Outcome == finding.OutcomeTrue {
return true
}
}
}
return false
}
func hasDWWithScriptInjection(findings []finding.Finding) bool {
for i := range findings {
f := &findings[i]
if f.Probe == hasDangerousWorkflowScriptInjection.Probe {
if f.Outcome == finding.OutcomeTrue {
return true
}
}
}
return false
}
// Copyright 2020 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/dependencyUpdateToolConfigured"
)
// DependencyUpdateTool applies the score policy and logs the details
// for the Dependency-Update-Tool check.
func DependencyUpdateTool(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
dependencyUpdateToolConfigured.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var usesTool bool
for i := range findings {
f := &findings[i]
var logLevel checker.DetailType
switch f.Outcome {
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
case finding.OutcomeTrue:
usesTool = true
logLevel = checker.DetailInfo
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
if usesTool {
return checker.CreateMaxScoreResult(name, "update tool detected")
}
return checker.CreateMinScoreResult(name, "no update tool detected")
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/fuzzed"
)
// Fuzzing applies the score policy for the Fuzzing check.
func Fuzzing(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
fuzzed.Probe,
}
// TODO: other packages to consider:
// - github.com/google/fuzztest
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var fuzzerDetected bool
// Compute the score.
for i := range findings {
f := &findings[i]
var logLevel checker.DetailType
switch f.Outcome {
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
case finding.OutcomeTrue:
fuzzerDetected = true
logLevel = checker.DetailInfo
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
if fuzzerDetected {
return checker.CreateMaxScoreResult(name, "project is fuzzed")
}
return checker.CreateMinScoreResult(name, "project is not fuzzed")
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasFSFOrOSIApprovedLicense"
"github.com/ossf/scorecard/v5/probes/hasLicenseFile"
)
// License applies the score policy for the License check.
func License(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasLicenseFile.Probe,
hasFSFOrOSIApprovedLicense.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Compute the score.
score := 0
m := make(map[string]bool)
var logLevel checker.DetailType
for i := range findings {
f := &findings[i]
switch f.Outcome {
case finding.OutcomeTrue:
logLevel = checker.DetailInfo
switch f.Probe {
case hasFSFOrOSIApprovedLicense.Probe:
score += scoreProbeOnce(f.Probe, m, 1)
case hasLicenseFile.Probe:
score += scoreProbeOnce(f.Probe, m, 9)
default:
e := sce.WithMessage(sce.ErrScorecardInternal, "unknown probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
_, defined := m[hasLicenseFile.Probe]
if !defined {
if score > 0 {
e := sce.WithMessage(sce.ErrScorecardInternal, "score calculation problem")
return checker.CreateRuntimeErrorResult(name, e)
}
return checker.CreateMinScoreResult(name, "license file not detected")
}
return checker.CreateResultWithScore(name, "license file detected", score)
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/archived"
"github.com/ossf/scorecard/v5/probes/createdRecently"
"github.com/ossf/scorecard/v5/probes/hasRecentCommits"
"github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember"
)
const (
lookBackDays = 90
activityPerWeek = 1
daysInOneWeek = 7
)
// Maintained applies the score policy for the Maintained check.
func Maintained(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
// We have 4 unique probes, each should have a finding.
expectedProbes := []string{
archived.Probe,
issueActivityByProjectMember.Probe,
hasRecentCommits.Probe,
createdRecently.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var isArchived, recentlyCreated bool
var commitsWithinThreshold, numberOfIssuesUpdatedWithinThreshold int
var err error
for i := range findings {
f := &findings[i]
switch f.Outcome {
case finding.OutcomeTrue:
switch f.Probe {
case issueActivityByProjectMember.Probe:
numberOfIssuesUpdatedWithinThreshold, err = strconv.Atoi(f.Values[issueActivityByProjectMember.NumIssuesKey])
if err != nil {
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, err.Error()))
}
case hasRecentCommits.Probe:
commitsWithinThreshold, err = strconv.Atoi(f.Values[hasRecentCommits.NumCommitsKey])
if err != nil {
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, err.Error()))
}
case archived.Probe:
isArchived = true
checker.LogFinding(dl, f, checker.DetailWarn)
case createdRecently.Probe:
recentlyCreated = true
checker.LogFinding(dl, f, checker.DetailWarn)
}
case finding.OutcomeFalse:
// both archive and created recently are good if false, and the
// other probes are informational and dont need logged. But we need
// to specify the case so it doesn't get logged below at the debug level
default:
checker.LogFinding(dl, f, checker.DetailDebug)
}
}
if isArchived {
return checker.CreateMinScoreResult(name, "project is archived")
}
if recentlyCreated {
return checker.CreateMinScoreResult(name,
"project was created within the last 90 days. Please review its contents carefully")
}
return checker.CreateProportionalScoreResult(name, fmt.Sprintf(
"%d commit(s) and %d issue activity found in the last %d days",
commitsWithinThreshold, numberOfIssuesUpdatedWithinThreshold, lookBackDays),
commitsWithinThreshold+numberOfIssuesUpdatedWithinThreshold, activityPerWeek*lookBackDays/daysInOneWeek)
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/packagedWithAutomatedWorkflow"
)
// Packaging applies the score policy for the Packaging check.
func Packaging(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
packagedWithAutomatedWorkflow.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Currently there is only a single packaging probe that returns
// a single true or false outcome. As such, in this evaluation,
// we return max score if the outcome is true and lowest score if
// the outcome is false.
maxScore := false
for i := range findings {
f := &findings[i]
var logLevel checker.DetailType
switch f.Outcome {
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
case finding.OutcomeTrue:
maxScore = true
logLevel = checker.DetailInfo
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
if maxScore {
return checker.CreateMaxScoreResult(name, "packaging workflow detected")
}
return checker.CreateInconclusiveResult(name, "packaging workflow not detected")
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasNoGitHubWorkflowPermissionUnknown"
"github.com/ossf/scorecard/v5/probes/jobLevelPermissions"
"github.com/ossf/scorecard/v5/probes/topLevelPermissions"
)
// TokenPermissions applies the score policy for the Token-Permissions check.
//
//nolint:gocognit
func TokenPermissions(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasNoGitHubWorkflowPermissionUnknown.Probe,
jobLevelPermissions.Probe,
topLevelPermissions.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Start with a perfect score.
score := float32(checker.MaxResultScore)
// hasWritePermissions is a map that holds information about the
// workflows in the project that have write permissions. It holds
// information about the write permissions of jobs and at the
// top-level too. The inner map (map[string]bool) has the
// workflow path as its key, and the value determines whether
// that workflow has write permissions at either "job" or "top"
// level.
hasWritePermissions := make(map[string]map[string]bool)
hasWritePermissions["jobLevel"] = make(map[string]bool)
hasWritePermissions["topLevel"] = make(map[string]bool)
// undeclaredPermissions is a map that holds information about the
// workflows in the project that have undeclared permissions. It holds
// information about the undeclared permissions of jobs and at the
// top-level too. The inner map (map[string]bool) has the
// workflow path as its key, and the value determines whether
// that workflow has undeclared permissions at either "job" or "top"
// level.
undeclaredPermissions := make(map[string]map[string]bool)
undeclaredPermissions["jobLevel"] = make(map[string]bool)
undeclaredPermissions["topLevel"] = make(map[string]bool)
for i := range findings {
f := &findings[i]
// Log workflows with "none" permissions
if permissionLevel(f) == checker.PermissionLevelNone {
dl.Info(&checker.LogMessage{
Finding: f,
})
continue
}
// Log workflows with "read" permissions
if permissionLevel(f) == checker.PermissionLevelRead {
dl.Info(&checker.LogMessage{
Finding: f,
})
}
if isBothUndeclaredAndNotAvailableOrNotApplicable(f, dl) {
return checker.CreateInconclusiveResult(name, "Token permissions are not available")
}
// If there are no TokenPermissions
if f.Outcome == finding.OutcomeNotApplicable {
return checker.CreateInconclusiveResult(name, "No tokens found")
}
if f.Outcome != finding.OutcomeFalse {
continue
}
if f.Location == nil {
continue
}
fPath := f.Location.Path
addProbeToMaps(fPath, undeclaredPermissions, hasWritePermissions)
if permissionLevel(f) == checker.PermissionLevelUndeclared {
score = updateScoreAndMapFromUndeclared(undeclaredPermissions,
hasWritePermissions, f, score, dl)
continue
}
switch f.Probe {
case hasNoGitHubWorkflowPermissionUnknown.Probe:
dl.Debug(&checker.LogMessage{
Finding: f,
})
case topLevelPermissions.Probe:
if permissionLevel(f) != checker.PermissionLevelWrite {
continue
}
hasWritePermissions["topLevel"][fPath] = true
if !isWriteAll(f) {
score -= reduceBy(f, dl)
continue
}
dl.Warn(&checker.LogMessage{
Finding: f,
})
// "all" is evaluated separately. If the project also has write permissions
// or undeclared permissions at the job level, this is particularly bad.
if hasWritePermissions["jobLevel"][fPath] ||
undeclaredPermissions["jobLevel"][fPath] {
return checker.CreateMinScoreResult(name, "detected GitHub workflow tokens with excessive permissions")
}
score -= 0.5
case jobLevelPermissions.Probe:
if permissionLevel(f) != checker.PermissionLevelWrite {
continue
}
dl.Warn(&checker.LogMessage{
Finding: f,
})
hasWritePermissions["jobLevel"][fPath] = true
// If project has "all" writepermissions too at top level, this is
// particularly bad.
if hasWritePermissions["topLevel"][fPath] {
score = checker.MinResultScore
break
}
// If project has not declared permissions at top level::
if undeclaredPermissions["topLevel"][fPath] {
score -= 0.5
}
default:
continue
}
}
if score < checker.MinResultScore {
score = checker.MinResultScore
}
logIfNoWritePermissionsFound(hasWritePermissions, dl)
if score != checker.MaxResultScore {
return checker.CreateResultWithScore(name,
"detected GitHub workflow tokens with excessive permissions", int(score))
}
return checker.CreateMaxScoreResult(name,
"GitHub workflow tokens follow principle of least privilege")
}
func logIfNoWritePermissionsFound(hasWritePermissions map[string]map[string]bool,
dl checker.DetailLogger,
) {
foundWritePermissions := false
for _, isWritePermission := range hasWritePermissions["jobLevel"] {
if isWritePermission {
foundWritePermissions = true
}
}
if !foundWritePermissions {
text := fmt.Sprintf("no %s write permissions found", checker.PermissionLocationJob)
dl.Info(&checker.LogMessage{
Text: text,
})
}
}
func updateScoreFromUndeclaredJob(undeclaredPermissions map[string]map[string]bool,
hasWritePermissions map[string]map[string]bool,
fPath string,
score float32,
) float32 {
if hasWritePermissions["topLevel"][fPath] ||
undeclaredPermissions["topLevel"][fPath] {
score = checker.MinResultScore
}
return score
}
func updateScoreFromUndeclaredTop(undeclaredPermissions map[string]map[string]bool,
fPath string,
score float32,
) float32 {
if undeclaredPermissions["jobLevel"][fPath] {
score = checker.MinResultScore
} else {
score -= 0.5
}
return score
}
func isBothUndeclaredAndNotAvailableOrNotApplicable(f *finding.Finding, dl checker.DetailLogger) bool {
if permissionLevel(f) == checker.PermissionLevelUndeclared {
if f.Outcome == finding.OutcomeNotAvailable {
return true
} else if f.Outcome == finding.OutcomeNotApplicable {
dl.Debug(&checker.LogMessage{
Finding: f,
})
return false
}
}
return false
}
func updateScoreAndMapFromUndeclared(undeclaredPermissions map[string]map[string]bool,
hasWritePermissions map[string]map[string]bool,
f *finding.Finding,
score float32, dl checker.DetailLogger,
) float32 {
fPath := f.Location.Path
if f.Probe == jobLevelPermissions.Probe {
dl.Debug(&checker.LogMessage{
Finding: f,
})
undeclaredPermissions["jobLevel"][fPath] = true
score = updateScoreFromUndeclaredJob(undeclaredPermissions,
hasWritePermissions,
fPath,
score)
} else if f.Probe == topLevelPermissions.Probe {
dl.Warn(&checker.LogMessage{
Finding: f,
})
undeclaredPermissions["topLevel"][fPath] = true
score = updateScoreFromUndeclaredTop(undeclaredPermissions,
fPath,
score)
}
return score
}
func addProbeToMaps(fPath string, hasWritePermissions, undeclaredPermissions map[string]map[string]bool) {
if _, ok := undeclaredPermissions["jobLevel"][fPath]; !ok {
undeclaredPermissions["jobLevel"][fPath] = false
}
if _, ok := undeclaredPermissions["topLevel"][fPath]; !ok {
undeclaredPermissions["topLevel"][fPath] = false
}
if _, ok := hasWritePermissions["jobLevel"][fPath]; !ok {
hasWritePermissions["jobLevel"][fPath] = false
}
if _, ok := hasWritePermissions["topLevel"][fPath]; !ok {
hasWritePermissions["topLevel"][fPath] = false
}
}
func reduceBy(f *finding.Finding, dl checker.DetailLogger) float32 {
if permissionLevel(f) != checker.PermissionLevelWrite {
return 0
}
switch tokenName(f) {
case "checks", "statuses":
dl.Warn(&checker.LogMessage{
Finding: f,
})
return 0.5
case "contents", "packages", "actions":
dl.Warn(&checker.LogMessage{
Finding: f,
})
return checker.MaxResultScore
case "deployments", "security-events":
dl.Warn(&checker.LogMessage{
Finding: f,
})
return 1.0
}
return 0
}
func isWriteAll(f *finding.Finding) bool {
token := tokenName(f)
return (token == "all" || token == "write-all")
}
func permissionLevel(f *finding.Finding) checker.PermissionLevel {
var key string
// these values should be the same, but better safe than sorry
switch f.Probe {
case jobLevelPermissions.Probe:
key = jobLevelPermissions.PermissionLevelKey
case topLevelPermissions.Probe:
key = topLevelPermissions.PermissionLevelKey
default:
}
return checker.PermissionLevel(f.Values[key])
}
func tokenName(f *finding.Finding) string {
var key string
// these values should be the same, but better safe than sorry
switch f.Probe {
case jobLevelPermissions.Probe:
key = jobLevelPermissions.TokenNameKey
case topLevelPermissions.Probe:
key = topLevelPermissions.TokenNameKey
default:
}
return f.Values[key]
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/pinsDependencies"
)
type pinnedResult struct {
pinned int
total int
}
// Structure to host information about pinned github
// or third party dependencies.
type workflowPinningResult struct {
thirdParties pinnedResult
gitHubOwned pinnedResult
}
// Weights used for proportional score.
// This defines the priority of pinning a dependency over other dependencies.
// The dependencies from all ecosystems are equally prioritized except
// for GitHub Actions. GitHub Actions can be GitHub-owned or from third-party
// development. The GitHub Actions ecosystem has equal priority compared to other
// ecosystems, but, within GitHub Actions, pinning third-party actions has more
// priority than pinning GitHub-owned actions.
// https://github.com/ossf/scorecard/issues/802
const (
gitHubOwnedActionWeight int = 2
thirdPartyActionWeight int = 8
normalWeight int = gitHubOwnedActionWeight + thirdPartyActionWeight
)
// PinningDependencies applies the score policy for the Pinned-Dependencies check.
func PinningDependencies(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
pinsDependencies.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var wp workflowPinningResult
pr := make(map[checker.DependencyUseType]pinnedResult)
for i := range findings {
f := findings[i]
switch f.Outcome {
case finding.OutcomeNotApplicable:
return checker.CreateInconclusiveResult(name, "no dependencies found")
case finding.OutcomeNotSupported:
dl.Debug(&checker.LogMessage{
Finding: &f,
})
continue
case finding.OutcomeFalse:
// we cant use the finding if we want the remediation to show
// finding.Remediation are currently suppressed (#3349)
lm := &checker.LogMessage{
Path: f.Location.Path,
Type: f.Location.Type,
Offset: *f.Location.LineStart,
EndOffset: *f.Location.LineEnd,
Text: f.Message,
Snippet: *f.Location.Snippet,
}
if f.Remediation != nil {
lm.Remediation = f.Remediation
}
dl.Warn(lm)
case finding.OutcomeError:
dl.Info(&checker.LogMessage{
Finding: &f,
})
continue
default:
// ignore
}
updatePinningResults(checker.DependencyUseType(f.Values[pinsDependencies.DepTypeKey]),
f.Outcome, f.Location.Snippet,
&wp, pr)
}
// Generate scores and Info results.
var scores []checker.ProportionalScoreWeighted
// Go through all dependency types
// GitHub Actions need to be handled separately since they are not in pr
scores = append(scores, createScoreForGitHubActionsWorkflow(&wp, dl)...)
// Only existing dependencies will be found in pr
// We will only score the ecosystem if there are dependencies
// This results in only existing ecosystems being included in the final score
for t := range pr {
logPinnedResult(dl, pr[t], string(t))
scores = append(scores, checker.ProportionalScoreWeighted{
Success: pr[t].pinned,
Total: pr[t].total,
Weight: normalWeight,
})
}
if len(scores) == 0 {
return checker.CreateInconclusiveResult(name, "no dependencies found")
}
score, err := checker.CreateProportionalScoreWeighted(scores...)
if err != nil {
return checker.CreateRuntimeErrorResult(name, err)
}
if score == checker.MaxResultScore {
return checker.CreateMaxScoreResult(name, "all dependencies are pinned")
}
return checker.CreateProportionalScoreResult(name,
"dependency not pinned by hash detected", score, checker.MaxResultScore)
}
func updatePinningResults(dependencyType checker.DependencyUseType,
outcome finding.Outcome, snippet *string,
wp *workflowPinningResult, pr map[checker.DependencyUseType]pinnedResult,
) {
if dependencyType == checker.DependencyUseTypeGHAction {
// Note: `Snippet` contains `action/name@xxx`, so we can use it to infer
// if it's a GitHub-owned action or not.
gitHubOwned := fileparser.IsGitHubOwnedAction(*snippet)
addWorkflowPinnedResult(outcome, wp, gitHubOwned)
return
}
// Update other result types.
p := pr[dependencyType]
addPinnedResult(outcome, &p)
pr[dependencyType] = p
}
func generateOwnerToDisplay(gitHubOwned bool) string {
if gitHubOwned {
return fmt.Sprintf("GitHub-owned %s", checker.DependencyUseTypeGHAction)
}
return fmt.Sprintf("third-party %s", checker.DependencyUseTypeGHAction)
}
func addPinnedResult(outcome finding.Outcome, r *pinnedResult) {
if outcome == finding.OutcomeTrue {
r.pinned += 1
}
r.total += 1
}
func addWorkflowPinnedResult(outcome finding.Outcome, w *workflowPinningResult, isGitHub bool) {
if isGitHub {
addPinnedResult(outcome, &w.gitHubOwned)
} else {
addPinnedResult(outcome, &w.thirdParties)
}
}
func logPinnedResult(dl checker.DetailLogger, p pinnedResult, name string) {
dl.Info(&checker.LogMessage{
Text: fmt.Sprintf("%3d out of %3d %s dependencies pinned", p.pinned, p.total, name),
})
}
func createScoreForGitHubActionsWorkflow(wp *workflowPinningResult, dl checker.DetailLogger,
) []checker.ProportionalScoreWeighted {
if wp.gitHubOwned.total == 0 && wp.thirdParties.total == 0 {
return []checker.ProportionalScoreWeighted{}
}
if wp.gitHubOwned.total != 0 && wp.thirdParties.total != 0 {
logPinnedResult(dl, wp.gitHubOwned, generateOwnerToDisplay(true))
logPinnedResult(dl, wp.thirdParties, generateOwnerToDisplay(false))
return []checker.ProportionalScoreWeighted{
{
Success: wp.gitHubOwned.pinned,
Total: wp.gitHubOwned.total,
Weight: gitHubOwnedActionWeight,
},
{
Success: wp.thirdParties.pinned,
Total: wp.thirdParties.total,
Weight: thirdPartyActionWeight,
},
}
}
if wp.gitHubOwned.total != 0 {
logPinnedResult(dl, wp.gitHubOwned, generateOwnerToDisplay(true))
return []checker.ProportionalScoreWeighted{
{
Success: wp.gitHubOwned.pinned,
Total: wp.gitHubOwned.total,
Weight: normalWeight,
},
}
}
logPinnedResult(dl, wp.thirdParties, generateOwnerToDisplay(false))
return []checker.ProportionalScoreWeighted{
{
Success: wp.thirdParties.pinned,
Total: wp.thirdParties.total,
Weight: normalWeight,
},
}
}
// Copyright 2023 OpenSSF 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 evaluation
import (
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/sastToolConfigured"
"github.com/ossf/scorecard/v5/probes/sastToolRunsOnAllCommits"
)
// SAST applies the score policy for the SAST check.
func SAST(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
sastToolConfigured.Probe,
sastToolRunsOnAllCommits.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var sastScore, codeQlScore, otherScore int
var err error
// Assign sastScore, codeQlScore and sonarScore
for i := range findings {
f := &findings[i]
switch f.Probe {
case sastToolRunsOnAllCommits.Probe:
sastScore, err = getSASTScore(f, dl)
if err != nil {
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, err.Error()))
}
case sastToolConfigured.Probe:
tool, ok := f.Values[sastToolConfigured.ToolKey]
if f.Outcome == finding.OutcomeTrue && !ok {
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, "missing SAST tool"))
}
score := getSastToolScore(f, dl)
switch checker.SASTWorkflowType(tool) {
case checker.CodeQLWorkflow:
codeQlScore = score
default:
otherScore = score
}
}
}
if otherScore == checker.MaxResultScore {
return checker.CreateMaxScoreResult(name, "SAST tool detected")
}
if sastScore == checker.InconclusiveResultScore &&
codeQlScore == checker.InconclusiveResultScore {
// That can never happen since sastToolInCheckRuns can never
// return checker.InconclusiveResultScore.
return checker.CreateRuntimeErrorResult(name, sce.ErrScorecardInternal)
}
// Both scores are conclusive.
// We assume the CodeQl config uses a cron and is not enabled as pre-submit.
// TODO: verify the above comment in code.
// We encourage developers to have sast check run on every pre-submit rather
// than as cron jobs through the score computation below.
// Warning: there is a hidden assumption that *any* sast tool is equally good.
if sastScore != checker.InconclusiveResultScore &&
codeQlScore != checker.InconclusiveResultScore {
switch {
case sastScore == checker.MaxResultScore:
return checker.CreateMaxScoreResult(name, "SAST tool is run on all commits")
case codeQlScore == checker.MinResultScore:
return checker.CreateResultWithScore(name,
checker.NormalizeReason("SAST tool is not run on all commits", sastScore), sastScore)
// codeQl is enabled and sast has 0+ (but not all) PRs checks.
case codeQlScore == checker.MaxResultScore:
const sastWeight = 3
const codeQlWeight = 7
score := checker.AggregateScoresWithWeight(map[int]int{sastScore: sastWeight, codeQlScore: codeQlWeight})
return checker.CreateResultWithScore(name, "SAST tool detected but not run on all commits", score)
default:
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, "contact team"))
}
}
// Sast inconclusive.
if codeQlScore != checker.InconclusiveResultScore {
if codeQlScore == checker.MaxResultScore {
return checker.CreateMaxScoreResult(name, "SAST tool detected: CodeQL")
}
return checker.CreateMinScoreResult(name, "no SAST tool detected")
}
// CodeQl inconclusive.
if sastScore != checker.InconclusiveResultScore {
if sastScore == checker.MaxResultScore {
return checker.CreateMaxScoreResult(name, "SAST tool is run on all commits")
}
return checker.CreateResultWithScore(name,
checker.NormalizeReason("SAST tool is not run on all commits", sastScore), sastScore)
}
// Should never happen.
return checker.CreateRuntimeErrorResult(name, sce.WithMessage(sce.ErrScorecardInternal, "contact team"))
}
// getSASTScore returns the proportional score of how many commits
// run SAST tools.
func getSASTScore(f *finding.Finding, dl checker.DetailLogger) (int, error) {
switch f.Outcome {
case finding.OutcomeNotApplicable:
dl.Warn(&checker.LogMessage{
Text: f.Message,
})
return checker.InconclusiveResultScore, nil
case finding.OutcomeTrue:
dl.Info(&checker.LogMessage{
Text: f.Message,
})
case finding.OutcomeFalse:
dl.Warn(&checker.LogMessage{
Text: f.Message,
})
default:
}
analyzed, err := strconv.Atoi(f.Values[sastToolRunsOnAllCommits.AnalyzedPRsKey])
if err != nil {
return 0, fmt.Errorf("parsing analyzed PR count: %w", err)
}
total, err := strconv.Atoi(f.Values[sastToolRunsOnAllCommits.TotalPRsKey])
if err != nil {
return 0, fmt.Errorf("parsing total PR count: %w", err)
}
return checker.CreateProportionalScore(analyzed, total), nil
}
// getSastToolScore returns true if the project runs the Sast tool
// and false if it doesn't.
func getSastToolScore(f *finding.Finding, dl checker.DetailLogger) int {
switch f.Outcome {
case finding.OutcomeTrue:
dl.Info(&checker.LogMessage{
Text: f.Message,
})
return checker.MaxResultScore
case finding.OutcomeFalse:
return checker.MinResultScore
default:
return checker.InconclusiveResultScore
}
}
// Copyright 2024 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasReleaseSBOM"
"github.com/ossf/scorecard/v5/probes/hasSBOM"
)
// SBOM applies the score policy for the SBOM check.
func SBOM(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
// We have 4 unique probes, each should have a finding.
expectedProbes := []string{
hasSBOM.Probe,
hasReleaseSBOM.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Compute the score.
score := 0
m := make(map[string]bool)
var logLevel checker.DetailType
for i := range findings {
f := &findings[i]
switch f.Outcome {
case finding.OutcomeTrue:
logLevel = checker.DetailInfo
switch f.Probe {
case hasSBOM.Probe:
score += scoreProbeOnce(f.Probe, m, 5)
case hasReleaseSBOM.Probe:
score += scoreProbeOnce(f.Probe, m, 5)
}
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
default:
continue // for linting
}
checker.LogFinding(dl, f, logLevel)
}
_, defined := m[hasSBOM.Probe]
if !defined {
return checker.CreateMinScoreResult(name, "SBOM file not detected")
}
_, defined = m[hasReleaseSBOM.Probe]
if defined {
return checker.CreateMaxScoreResult(name, "SBOM file found in release artifacts")
}
return checker.CreateResultWithScore(name, "SBOM file found in project", score)
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsLinks"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsText"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsVulnerabilityDisclosure"
"github.com/ossf/scorecard/v5/probes/securityPolicyPresent"
)
// SecurityPolicy applies the score policy for the Security-Policy check.
func SecurityPolicy(name string, findings []finding.Finding, dl checker.DetailLogger) checker.CheckResult {
// We have 4 unique probes, each should have a finding.
expectedProbes := []string{
securityPolicyContainsVulnerabilityDisclosure.Probe,
securityPolicyContainsLinks.Probe,
securityPolicyContainsText.Probe,
securityPolicyPresent.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
score := 0
m := make(map[string]bool)
var logLevel checker.DetailType
for i := range findings {
f := &findings[i]
// all of the security policy probes are good things if true and bad if false
switch f.Outcome {
case finding.OutcomeTrue:
logLevel = checker.DetailInfo
switch f.Probe {
case securityPolicyContainsVulnerabilityDisclosure.Probe:
score += scoreProbeOnce(f.Probe, m, 1)
case securityPolicyContainsLinks.Probe:
score += scoreProbeOnce(f.Probe, m, 6)
case securityPolicyContainsText.Probe:
score += scoreProbeOnce(f.Probe, m, 3)
case securityPolicyPresent.Probe:
m[f.Probe] = true
default:
e := sce.WithMessage(sce.ErrScorecardInternal, "unknown probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
_, defined := m[securityPolicyPresent.Probe]
if !defined {
if score > 0 {
e := sce.WithMessage(sce.ErrScorecardInternal, "score calculation problem")
return checker.CreateRuntimeErrorResult(name, e)
}
return checker.CreateMinScoreResult(name, "security policy file not detected")
}
return checker.CreateResultWithScore(name, "security policy file detected", score)
}
func scoreProbeOnce(probeID string, m map[string]bool, bump int) int {
if _, exists := m[probeID]; !exists {
m[probeID] = true
return bump
}
return 0
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"errors"
"fmt"
"math"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/releasesAreSigned"
"github.com/ossf/scorecard/v5/probes/releasesHaveProvenance"
"github.com/ossf/scorecard/v5/probes/releasesHaveVerifiedProvenance"
)
var errNoReleaseFound = errors.New("no release found")
// SignedReleases applies the score policy for the Signed-Releases check.
//
//nolint:gocognit // surpressing for now
func SignedReleases(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
releasesAreSigned.Probe,
releasesHaveProvenance.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// keep track of releases which have provenance so we don't log about signatures
// on our second pass through below
hasProvenance := make(map[string]bool)
// Debug all releases and check for OutcomeNotApplicable
// All probes have OutcomeNotApplicable in case the project has no
// releases. Therefore, check for any finding with OutcomeNotApplicable.
loggedReleases := make([]string, 0)
for i := range findings {
f := &findings[i]
if f.Probe == releasesHaveVerifiedProvenance.Probe {
continue
}
// Debug release name
if f.Outcome == finding.OutcomeNotApplicable {
// Generic summary.
return checker.CreateInconclusiveResult(name, "no releases found")
}
releaseName := getReleaseName(f)
if releaseName == "" {
// Generic summary.
return checker.CreateRuntimeErrorResult(name, errNoReleaseFound)
}
if !contains(loggedReleases, releaseName) {
dl.Debug(&checker.LogMessage{
Text: fmt.Sprintf("GitHub release found: %s", releaseName),
})
loggedReleases = append(loggedReleases, releaseName)
}
if f.Probe == releasesHaveProvenance.Probe && f.Outcome == finding.OutcomeTrue {
hasProvenance[releaseName] = true
}
}
totalTrue := 0
releaseMap := make(map[string]int)
uniqueReleaseTags := make([]string, 0)
var logLevel checker.DetailType
for i := range findings {
f := &findings[i]
if f.Probe == releasesHaveVerifiedProvenance.Probe {
continue
}
releaseName := getReleaseName(f)
if releaseName == "" {
return checker.CreateRuntimeErrorResult(name, errNoReleaseFound)
}
if !contains(uniqueReleaseTags, releaseName) {
uniqueReleaseTags = append(uniqueReleaseTags, releaseName)
}
switch f.Outcome {
case finding.OutcomeTrue:
logLevel = checker.DetailInfo
totalTrue++
switch f.Probe {
case releasesAreSigned.Probe:
if _, ok := releaseMap[releaseName]; !ok {
releaseMap[releaseName] = 8
}
case releasesHaveProvenance.Probe:
releaseMap[releaseName] = 10
}
case finding.OutcomeFalse:
logLevel = checker.DetailWarn
if f.Probe == releasesAreSigned.Probe && hasProvenance[releaseName] {
continue
}
default:
logLevel = checker.DetailDebug
}
checker.LogFinding(dl, f, logLevel)
}
if totalTrue == 0 {
return checker.CreateMinScoreResult(name, "Project has not signed or included provenance with any releases.")
}
totalReleases := len(uniqueReleaseTags)
// TODO, the evaluation code should be the one limiting to 5, not assuming the probes have done it already
// however there are some ordering issues to consider, so going with the easy way for now
if totalReleases > 5 {
err := sce.CreateInternal(sce.ErrScorecardInternal, "too many releases, please report this")
return checker.CreateRuntimeErrorResult(name, err)
}
if totalReleases == 0 {
// This should not happen in production, but it is useful to have
// for testing.
return checker.CreateInconclusiveResult(name, "no releases found")
}
score := 0
for _, s := range releaseMap {
score += s
}
score = int(math.Floor(float64(score) / float64(totalReleases)))
reason := fmt.Sprintf("%d out of the last %d releases have a total of %d signed artifacts.",
len(releaseMap), totalReleases, totalTrue)
return checker.CreateResultWithScore(name, reason, score)
}
func getReleaseName(f *finding.Finding) string {
var key string
// these keys should be the same, but might as handle situations when they're not
switch f.Probe {
case releasesAreSigned.Probe:
key = releasesAreSigned.ReleaseNameKey
case releasesHaveProvenance.Probe:
key = releasesHaveProvenance.ReleaseNameKey
}
return f.Values[key]
}
func contains(releases []string, release string) bool {
for _, r := range releases {
if r == release {
return true
}
}
return false
}
// Copyright 2022 OpenSSF 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 evaluation
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities"
)
// Vulnerabilities applies the score policy for the Vulnerabilities check.
func Vulnerabilities(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasOSVVulnerabilities.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
var numVulnsFound int
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeTrue {
numVulnsFound++
checker.LogFinding(dl, f, checker.DetailWarn)
}
}
score := checker.MaxResultScore - numVulnsFound
if score < checker.MinResultScore {
score = checker.MinResultScore
}
return checker.CreateResultWithScore(name,
fmt.Sprintf("%v existing vulnerabilities detected", numVulnsFound), score)
}
// Copyright 2021 OpenSSF 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 evaluation
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/webhooksUseSecrets"
)
// Webhooks applies the score policy for the Webhooks check.
func Webhooks(name string,
findings []finding.Finding, dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
webhooksUseSecrets.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
if len(findings) == 1 && findings[0].Outcome == finding.OutcomeNotApplicable {
return checker.CreateMaxScoreResult(name, "project does not have webhook")
}
var webhooksWithNoSecret int
totalWebhooks := len(findings)
for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeFalse {
webhooksWithNoSecret++
}
}
if totalWebhooks == webhooksWithNoSecret {
return checker.CreateMinScoreResult(name, "no hook(s) have a secret configured")
}
if webhooksWithNoSecret == 0 {
msg := fmt.Sprintf("All %d of the projects webhooks are configured with a secret", totalWebhooks)
return checker.CreateMaxScoreResult(name, msg)
}
msg := fmt.Sprintf("%d out of the projects %d webhooks are configured without a secret",
webhooksWithNoSecret,
totalWebhooks)
return checker.CreateProportionalScoreResult(name,
msg, totalWebhooks-webhooksWithNoSecret, totalWebhooks)
}
// Copyright 2021 OpenSSF 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 fileparser
import (
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
const (
// defaultShellNonWindows is the default shell used for GitHub workflow actions for Linux and Mac.
defaultShellNonWindows = "bash"
// defaultShellWindows is the default shell used for GitHub workflow actions for Windows.
defaultShellWindows = "pwsh"
windows = "windows"
os = "os"
matrixos = "matrix.os"
)
// GetJobName returns Name.Value if non-nil, else returns "".
func GetJobName(job *actionlint.Job) string {
if job != nil && job.Name != nil {
return job.Name.Value
}
return ""
}
// GetStepName returns Name.Value if non-nil, else returns "".
func GetStepName(step *actionlint.Step) string {
if step != nil && step.Name != nil {
return step.Name.Value
}
return ""
}
// IsStepExecKind compares input `step` ExecKind with `kind` and returns true on a match.
func IsStepExecKind(step *actionlint.Step, kind actionlint.ExecKind) bool {
if step == nil || step.Exec == nil {
return false
}
return step.Exec.Kind() == kind
}
// GetLineNumber returns the line number for this position.
func GetLineNumber(pos *actionlint.Pos) uint {
if pos == nil {
return checker.OffsetDefault
}
return uint(pos.Line)
}
// GetUses returns the 'uses' statement in this step or nil if this step does not have one.
func GetUses(step *actionlint.Step) *actionlint.String {
if step == nil {
return nil
}
if !IsStepExecKind(step, actionlint.ExecKindAction) {
return nil
}
execAction, ok := step.Exec.(*actionlint.ExecAction)
if !ok || execAction == nil {
return nil
}
return execAction.Uses
}
// getWith returns the 'with' statement in this step or nil if this step does not have one.
func getWith(step *actionlint.Step) map[string]*actionlint.Input {
if step == nil {
return nil
}
if !IsStepExecKind(step, actionlint.ExecKindAction) {
return nil
}
execAction, ok := step.Exec.(*actionlint.ExecAction)
if !ok || execAction == nil {
return nil
}
return execAction.Inputs
}
// getRun returns the 'run' statement in this step or nil if this step does not have one.
func getRun(step *actionlint.Step) *actionlint.String {
if step == nil {
return nil
}
if !IsStepExecKind(step, actionlint.ExecKindRun) {
return nil
}
execAction, ok := step.Exec.(*actionlint.ExecRun)
if !ok || execAction == nil {
return nil
}
return execAction.Run
}
func getExecRunShell(execRun *actionlint.ExecRun) string {
if execRun != nil && execRun.Shell != nil {
return execRun.Shell.Value
}
return ""
}
func getJobDefaultRunShell(job *actionlint.Job) string {
if job != nil && job.Defaults != nil && job.Defaults.Run != nil && job.Defaults.Run.Shell != nil {
return job.Defaults.Run.Shell.Value
}
return ""
}
func getJobRunsOnLabels(job *actionlint.Job) []*actionlint.String {
if job != nil && job.RunsOn != nil {
// Starting at v1.6.16, either field may be set
// https://github.com/rhysd/actionlint/issues/164
if job.RunsOn.LabelsExpr != nil {
return []*actionlint.String{job.RunsOn.LabelsExpr}
}
return job.RunsOn.Labels
}
return nil
}
func getJobStrategyMatrixRows(job *actionlint.Job) map[string]*actionlint.MatrixRow {
if job != nil && job.Strategy != nil && job.Strategy.Matrix != nil {
return job.Strategy.Matrix.Rows
}
return nil
}
func getJobStrategyMatrixIncludeCombinations(job *actionlint.Job) []*actionlint.MatrixCombination {
if job != nil && job.Strategy != nil && job.Strategy.Matrix != nil && job.Strategy.Matrix.Include != nil &&
job.Strategy.Matrix.Include.Combinations != nil {
return job.Strategy.Matrix.Include.Combinations
}
return nil
}
// FormatActionlintError combines the errors into a single one.
func FormatActionlintError(errs []*actionlint.Error) error {
if len(errs) == 0 {
return nil
}
builder := strings.Builder{}
builder.WriteString(errInvalidGitHubWorkflow.Error() + ":")
for _, err := range errs {
builder.WriteString("\n" + err.Error())
}
return sce.WithMessage(sce.ErrScorecardInternal, builder.String())
}
// GetOSesForJob returns the OSes this job runs on.
func GetOSesForJob(job *actionlint.Job) ([]string, error) {
// The 'runs-on' field either lists the OS'es directly, or it can have an expression '${{ matrix.os }}' which
// is where the OS'es are actually listed.
jobOSes := make([]string, 0)
jobRunsOnLabels := getJobRunsOnLabels(job)
getFromMatrix := len(jobRunsOnLabels) == 1 && strings.Contains(jobRunsOnLabels[0].Value, matrixos)
if !getFromMatrix {
// We can get the OSes straight from 'runs-on'.
for _, os := range jobRunsOnLabels {
jobOSes = append(jobOSes, os.Value)
}
return jobOSes, nil
}
jobStrategyMatrixRows := getJobStrategyMatrixRows(job)
for rowKey, rowValue := range jobStrategyMatrixRows {
if rowKey != os {
continue
}
for _, os := range rowValue.Values {
jobOSes = append(jobOSes, strings.Trim(os.String(), "'\""))
}
}
matrixCombinations := getJobStrategyMatrixIncludeCombinations(job)
for _, combination := range matrixCombinations {
if combination.Assigns == nil {
continue
}
for _, assign := range combination.Assigns {
if assign.Key == nil || assign.Key.Value != os || assign.Value == nil {
continue
}
jobOSes = append(jobOSes, strings.Trim(assign.Value.String(), "'\""))
}
}
if len(jobOSes) == 0 {
// This error is caught by the caller, which is responsible for adding more
// precise location information
jobName := GetJobName(job)
return jobOSes, &checker.ElementError{
Location: finding.Location{
Snippet: &jobName,
},
Err: sce.ErrJobOSParsing,
}
}
return jobOSes, nil
}
// JobAlwaysRunsOnWindows returns true if the only OS that this job runs on is Windows.
func JobAlwaysRunsOnWindows(job *actionlint.Job) (bool, error) {
jobOSes, err := GetOSesForJob(job)
if err != nil {
return false, err
}
for _, os := range jobOSes {
if !strings.HasPrefix(strings.ToLower(os), windows) {
return false, nil
}
}
return true, nil
}
// GetShellForStep returns the shell that is used to run the given step.
func GetShellForStep(step *actionlint.Step, job *actionlint.Job) (string, error) {
// https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#using-a-specific-shell.
execRun, ok := step.Exec.(*actionlint.ExecRun)
if !ok {
jobName := GetJobName(job)
stepName := GetStepName(step)
return "", sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("unable to parse step '%v' for job '%v'", jobName, stepName))
}
execRunShell := getExecRunShell(execRun)
if execRunShell != "" {
return execRunShell, nil
}
jobDefaultRunShell := getJobDefaultRunShell(job)
if jobDefaultRunShell != "" {
return jobDefaultRunShell, nil
}
isStepWindows, err := IsStepWindows(step)
if err != nil {
return "", err
}
if isStepWindows {
return defaultShellWindows, nil
}
alwaysRunsOnWindows, err := JobAlwaysRunsOnWindows(job)
if err != nil {
return "", err
}
if alwaysRunsOnWindows {
return defaultShellWindows, nil
}
return defaultShellNonWindows, nil
}
// IsStepWindows returns true if the step will be run on Windows.
func IsStepWindows(step *actionlint.Step) (bool, error) {
if step.If == nil {
return false, nil
}
windowsRegexes := []string{
// Looking for "if: runner.os == 'Windows'" (and variants)
`(?i)runner\.os\s*==\s*['"]windows['"]`,
// Looking for "if: ${{ startsWith(runner.os, 'Windows') }}" (and variants)
`(?i)\$\{\{\s*startsWith\(runner\.os,\s*['"]windows['"]\)`,
// Looking for "if: matrix.os == 'windows-2019'" (and variants)
`(?i)matrix\.os\s*==\s*['"]windows-`,
}
for _, windowsRegex := range windowsRegexes {
matches, err := regexp.MatchString(windowsRegex, step.If.Value)
if err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("error matching Windows regex: %v", err))
}
if matches {
return true, nil
}
}
return false, nil
}
// IsWorkflowFile returns true if this is a GitHub workflow file.
func IsWorkflowFile(pathfn string) bool {
// From https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions:
// "Workflow files use YAML syntax, and must have either a .yml or .yaml file extension."
switch path.Ext(pathfn) {
case ".yml", ".yaml":
return filepath.Dir(strings.ToLower(pathfn)) == ".github/workflows"
default:
return false
}
}
// IsGithubWorkflowFileCb determines if a file is a workflow
// as a callback to use for repo client's ListFiles() API.
func IsGithubWorkflowFileCb(pathfn string) (bool, error) {
return IsWorkflowFile(pathfn), nil
}
// IsGitHubOwnedAction checks if this is a github specific action.
func IsGitHubOwnedAction(actionName string) bool {
a := strings.HasPrefix(actionName, "actions/")
c := strings.HasPrefix(actionName, "github/")
return a || c
}
// JobMatcher is rule for matching a job.
type JobMatcher struct {
// The text to be logged when a job match is found.
LogText string
// Each step in this field has a matching step in the job.
Steps []*JobMatcherStep
}
// JobMatcherStep is a single step that needs to be matched.
type JobMatcherStep struct {
// If set, the step's 'Uses' must match this field. Checks that the action name is the same.
Uses string
// If set, the step's 'With' have the keys and values that are in this field.
With map[string]string
// If set, the step's 'Run' must match this field. Does a regex match using this field.
Run string
}
// JobMatchResult represents the result of a match.
type JobMatchResult struct {
Msg string
File checker.File
}
// AnyJobsMatch returns true if any of the jobs have a match in the given workflow.
func AnyJobsMatch(workflow *actionlint.Workflow, jobMatchers []JobMatcher, fp string,
logMsgNoMatch string,
) (JobMatchResult, bool) {
for _, job := range workflow.Jobs {
for _, matcher := range jobMatchers {
if !matcher.matches(job) {
continue
}
return JobMatchResult{
File: checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: GetLineNumber(job.Pos),
},
Msg: fmt.Sprintf("%v: %v", matcher.LogText, fp),
}, true
}
}
return JobMatchResult{
File: checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
Msg: fmt.Sprintf("%v: %v", logMsgNoMatch, fp),
}, false
}
// matches returns true if the job matches the job matcher.
func (m *JobMatcher) matches(job *actionlint.Job) bool {
for _, stepToMatch := range m.Steps {
hasMatch := false
// First look for re-usable workflow calls.
if job.WorkflowCall != nil &&
job.WorkflowCall.Uses != nil &&
strings.HasPrefix(job.WorkflowCall.Uses.Value, stepToMatch.Uses+"@") {
return true
}
// Second looks for steps in the job.
for _, step := range job.Steps {
if stepsMatch(stepToMatch, step) {
hasMatch = true
break
}
}
if !hasMatch {
return false
}
}
return true
}
// stepsMatch returns true if the fields on 'stepToMatch' match what's in 'step'.
func stepsMatch(stepToMatch *JobMatcherStep, step *actionlint.Step) bool {
// Make sure 'uses' matches if present.
if stepToMatch.Uses != "" {
uses := GetUses(step)
if uses == nil {
return false
}
if !strings.HasPrefix(uses.Value, stepToMatch.Uses+"@") {
return false
}
}
// Make sure 'with' matches if present.
if len(stepToMatch.With) > 0 {
with := getWith(step)
if with == nil {
return false
}
for keyToMatch, valToMatch := range stepToMatch.With {
input, ok := with[keyToMatch]
if !ok || input == nil || input.Value == nil || input.Value.Value != valToMatch {
return false
}
}
}
// Make sure 'run' matches if present.
if stepToMatch.Run != "" {
run := getRun(step)
if run == nil {
return false
}
withoutLineContinuations := regexp.MustCompile("\\\\(\n|\r|\r\n)").ReplaceAllString(run.Value, "")
r := regexp.MustCompile(stepToMatch.Run)
if !r.MatchString(withoutLineContinuations) {
return false
}
}
return true
}
// IsPackagingWorkflow checks for a packaging workflow.
func IsPackagingWorkflow(workflow *actionlint.Workflow, fp string) (JobMatchResult, bool) {
jobMatchers := []JobMatcher{
{
Steps: []*JobMatcherStep{
{
Uses: "actions/setup-node",
With: map[string]string{"registry-url": "https://registry.npmjs.org"},
},
{
Run: "npm.*publish",
},
},
LogText: "candidate node publishing workflow using npm",
},
{
// Java packages with maven.
Steps: []*JobMatcherStep{
{
Uses: "actions/setup-java",
},
{
Run: "mvn.*deploy",
},
},
LogText: "candidate java publishing workflow using maven",
},
{
// Java packages with gradle.
Steps: []*JobMatcherStep{
{
Uses: "actions/setup-java",
},
{
Run: "gradle.*publish",
},
},
LogText: "candidate java publishing workflow using gradle",
},
{
// Scala packages with sbt-ci-release
Steps: []*JobMatcherStep{
{
Run: "sbt.*ci-release",
},
},
LogText: "candidate Scala publishing workflow using sbt-ci-release",
},
{
// Ruby packages.
Steps: []*JobMatcherStep{
{
Run: "gem.*push",
},
},
LogText: "candidate ruby publishing workflow using gem",
},
{
// NuGet packages.
Steps: []*JobMatcherStep{
{
Run: "nuget.*push",
},
},
LogText: "candidate nuget publishing workflow",
},
{
// Docker packages.
Steps: []*JobMatcherStep{
{
Run: "docker.*push",
},
},
LogText: "candidate docker publishing workflow",
},
{
// Docker packages.
Steps: []*JobMatcherStep{
{
Uses: "docker/build-push-action",
},
},
LogText: "candidate docker publishing workflow",
},
{
// Python packages.
Steps: []*JobMatcherStep{
{
Uses: "pypa/gh-action-pypi-publish",
},
},
LogText: "candidate python publishing workflow using pypi",
},
{
// Python packages.
// This is a custom Python packaging workflow based on semantic versioning.
// TODO(#1642): accept custom workflows through a separate configuration.
Steps: []*JobMatcherStep{
{
Uses: "relekang/python-semantic-release",
},
},
LogText: "candidate python publishing workflow using python-semantic-release",
},
{
// Go packages.
Steps: []*JobMatcherStep{
{
Uses: "goreleaser/goreleaser-action",
},
},
LogText: "candidate golang publishing workflow",
},
{
// Rust packages. https://doc.rust-lang.org/cargo/reference/publishing.html
Steps: []*JobMatcherStep{
{
Run: "cargo.*publish",
},
},
LogText: "candidate rust publishing workflow using cargo",
},
{
// Ko container action. https://github.com/google/ko
Steps: []*JobMatcherStep{
{
Uses: "imjasonh/setup-ko",
},
{
Uses: "ko-build/setup-ko",
},
},
LogText: "candidate container publishing workflow using ko",
},
{
// Commonly JavaScript packages, but supports multiple ecosystems
Steps: []*JobMatcherStep{
{
Run: "npx.*semantic-release",
},
},
LogText: "candidate publishing workflow using semantic-release",
},
}
return AnyJobsMatch(workflow, jobMatchers, fp, "not a publishing workflow")
}
// Copyright 2021 OpenSSF 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 fileparser
// IsGitlabWorkflowFile determines if a file is a workflow
// as a callback to use for repo client's ListFiles() API.
func IsGitlabWorkflowFile(pathfn string) (bool, error) {
return pathfn == "gitlabscorecard_flattened_ci.yaml", nil
}
// Copyright 2020 OpenSSF 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 fileparser
import (
"bufio"
"fmt"
"io"
"path"
"strings"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
// isMatchingPath uses 'pattern' to shell-match the 'path' and its filename
// 'caseSensitive' indicates the match should be case-sensitive. Default: no.
func isMatchingPath(fullpath string, matchPathTo PathMatcher) (bool, error) {
pattern := matchPathTo.Pattern
if !matchPathTo.CaseSensitive {
pattern = strings.ToLower(matchPathTo.Pattern)
fullpath = strings.ToLower(fullpath)
}
filename := path.Base(fullpath)
match, err := path.Match(pattern, fullpath)
if err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInternalFilenameMatch, err))
}
// No match on the fullpath, let's try on the filename only.
if !match {
if match, err = path.Match(pattern, filename); err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInternalFilenameMatch, err))
}
}
return match, nil
}
func isTestdataFile(fullpath string) bool {
// testdata/ or /some/dir/testdata/some/other
return strings.HasPrefix(fullpath, "testdata/") ||
strings.Contains(fullpath, "/testdata/") ||
strings.HasPrefix(fullpath, "src/test/") ||
strings.Contains(fullpath, "/src/test/")
}
// PathMatcher represents a query for a filepath.
type PathMatcher struct {
Pattern string
CaseSensitive bool
}
// DoWhileTrueOnFileReader takes a filepath, its reader and
// optional variadic args. It returns a boolean indicating whether
// iterating over next files should continue.
type DoWhileTrueOnFileReader func(path string, reader io.Reader, args ...interface{}) (bool, error)
// OnMatchingFileReaderDo matches all files listed by `repoClient` against `matchPathTo`
// and on every successful match, runs onFileReader fn on the file's reader.
// Continues iterating along the matched files until onFileReader returns
// either a false value or an error.
func OnMatchingFileReaderDo(repoClient clients.RepoClient, matchPathTo PathMatcher,
onFileReader DoWhileTrueOnFileReader, args ...interface{},
) error {
return onMatchingFileDo(repoClient, matchPathTo, onFileReader, args...)
}
// DoWhileTrueOnFileContent takes a filepath, its content and
// optional variadic args. It returns a boolean indicating whether
// iterating over next files should continue.
type DoWhileTrueOnFileContent func(path string, content []byte, args ...interface{}) (bool, error)
// OnMatchingFileContentDo matches all files listed by `repoClient` against `matchPathTo`
// and on every successful match, runs onFileContent fn on the file's contents.
// Continues iterating along the matched files until onFileContent returns
// either a false value or an error.
func OnMatchingFileContentDo(repoClient clients.RepoClient, matchPathTo PathMatcher,
onFileContent DoWhileTrueOnFileContent, args ...interface{},
) error {
return onMatchingFileDo(repoClient, matchPathTo, onFileContent, args...)
}
func onMatchingFileDo(repoClient clients.RepoClient, matchPathTo PathMatcher,
onFile any, args ...interface{},
) error {
predicate := func(filepath string) (bool, error) {
// Filter out test files.
if isTestdataFile(filepath) {
return false, nil
}
// Filter out files based on path/names using the pattern.
b, err := isMatchingPath(filepath, matchPathTo)
if err != nil {
return false, err
}
return b, nil
}
matchedFiles, err := repoClient.ListFiles(predicate)
if err != nil {
return fmt.Errorf("error during ListFiles: %w", err)
}
for _, file := range matchedFiles {
reader, err := repoClient.GetFileReader(file)
if err != nil {
return fmt.Errorf("error during GetFileReader: %w", err)
}
var continueIter bool
switch f := onFile.(type) {
case DoWhileTrueOnFileReader:
continueIter, err = f(file, reader, args...)
reader.Close()
case DoWhileTrueOnFileContent:
var content []byte
content, err = io.ReadAll(reader)
reader.Close()
if err != nil {
return fmt.Errorf("reading from file: %w", err)
}
continueIter, err = f(file, content, args...)
default:
msg := fmt.Sprintf("invalid type (%T) passed to onMatchingFileDo", f)
return sce.WithMessage(sce.ErrScorecardInternal, msg)
}
if err != nil {
return err
}
if !continueIter {
break
}
}
return nil
}
// DoWhileTrueOnFilename takes a filename and optional variadic args and returns
// true if the next filename should continue to be processed.
type DoWhileTrueOnFilename func(path string, args ...interface{}) (bool, error)
// OnAllFilesDo iterates through all files returned by `repoClient` and
// calls `onFile` fn on them until `onFile` returns error or a false value.
func OnAllFilesDo(repoClient clients.RepoClient, onFile DoWhileTrueOnFilename, args ...interface{}) error {
matchedFiles, err := repoClient.ListFiles(func(string) (bool, error) { return true, nil })
if err != nil {
return fmt.Errorf("error during ListFiles: %w", err)
}
for _, filename := range matchedFiles {
continueIter, err := onFile(filename, args...)
if err != nil {
return err
}
if !continueIter {
break
}
}
return nil
}
// CheckFileContainsCommands checks if the file content contains commands or not.
// `comment` is the string or character that indicates a comment:
// for example for Dockerfiles, it would be `#`.
func CheckFileContainsCommands(content []byte, comment string) bool {
if len(content) == 0 {
return false
}
r := strings.NewReader(string(content))
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, comment) {
return true
}
}
return false
}
// IsTemplateFile returns true if the file name contains a string commonly used in template files.
func IsTemplateFile(pathfn string) bool {
parts := strings.FieldsFunc(path.Base(pathfn), func(r rune) bool {
switch r {
case '.', '-', '_':
return true
default:
return false
}
})
for _, part := range parts {
switch strings.ToLower(part) {
case "template", "tmpl", "tpl":
return true
}
}
return false
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckFuzzing is the registered name for Fuzzing.
const CheckFuzzing = "Fuzzing"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.FileBased,
}
if err := registerCheck(CheckFuzzing, Fuzzing, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// Fuzzing runs Fuzzing check.
func Fuzzing(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.Fuzzing(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckFuzzing, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.FuzzingResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.Fuzzing)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckFuzzing, e)
}
// Return the score evaluation.
ret := evaluation.Fuzzing(CheckFuzzing, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckLicense is the registered name for License.
const CheckLicense = "License"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckLicense, License, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// License runs License check.
func License(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.License(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckLicense, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.LicenseResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.License)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckLicense, e)
}
ret := evaluation.License(CheckLicense, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckMaintained is the exported check name for Maintained.
const CheckMaintained = "Maintained"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckMaintained, Maintained, nil); err != nil {
// this should never happen
panic(err)
}
}
// Maintained runs Maintained check.
func Maintained(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.Maintained(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckMaintained, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.MaintainedResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.Maintained)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckMaintained, e)
}
// Return the score evaluation.
ret := evaluation.Maintained(CheckMaintained, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw/github"
"github.com/ossf/scorecard/v5/checks/raw/gitlab"
"github.com/ossf/scorecard/v5/clients/githubrepo"
"github.com/ossf/scorecard/v5/clients/gitlabrepo"
"github.com/ossf/scorecard/v5/clients/localdir"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckPackaging is the registered name for Packaging.
const CheckPackaging = "Packaging"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.FileBased,
}
if err := registerCheck(CheckPackaging, Packaging, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// Packaging runs Packaging check.
func Packaging(c *checker.CheckRequest) checker.CheckResult {
var rawData, rawDataGithub, rawDataGitlab checker.PackagingData
var err, errGithub, errGitlab error
switch v := c.RepoClient.(type) {
case *localdir.Client:
// Performing both packaging checks since we dont know when local
rawDataGithub, errGithub = github.Packaging(c)
rawDataGitlab, errGitlab = gitlab.Packaging(c)
// Appending results of checks
rawData.Packages = append(rawData.Packages, rawDataGithub.Packages...)
rawData.Packages = append(rawData.Packages, rawDataGitlab.Packages...)
// checking for errors
if errGithub != nil {
err = errGithub
} else if errGitlab != nil {
err = errGitlab
}
case *githubrepo.Client:
rawData, err = github.Packaging(c)
case *gitlabrepo.Client:
rawData, err = gitlab.Packaging(c)
default:
_ = v
}
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckPackaging, e)
}
pRawResults := getRawResults(c)
pRawResults.PackagingResults = rawData
findings, err := zrunner.Run(pRawResults, probes.Packaging)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckPackaging, e)
}
ret := evaluation.Packaging(CheckPackaging, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2021 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckTokenPermissions is the exported name for Token-Permissions check.
const CheckTokenPermissions = "Token-Permissions"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckTokenPermissions, TokenPermissions, supportedRequestTypes); err != nil {
// This should never happen.
panic(err)
}
}
// TokenPermissions will run the Token-Permissions check.
func TokenPermissions(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.TokenPermissions(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckTokenPermissions, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.TokenPermissionsResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.TokenPermissions)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckTokenPermissions, e)
}
// Return the score evaluation.
ret := evaluation.TokenPermissions(CheckTokenPermissions, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2021 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckPinnedDependencies is the registered name for FrozenDeps.
const CheckPinnedDependencies = "Pinned-Dependencies"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckPinnedDependencies, PinningDependencies, supportedRequestTypes); err != nil {
// This should never happen.
panic(err)
}
}
// PinningDependencies will check the repository for its use of dependencies.
func PinningDependencies(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.PinningDependencies(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckPinnedDependencies, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.PinningDependenciesResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.PinnedDependencies)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckPinnedDependencies, e)
}
// Return the score evaluation.
ret := evaluation.PinningDependencies(CheckPinnedDependencies, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2023 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
)
// getRawResults returns a pointer to the raw results in the CheckRequest
// if the pointer is not nil. Else, it creates a new raw result.
func getRawResults(c *checker.CheckRequest) *checker.RawResults {
if c.RawResults != nil {
return c.RawResults
}
return &checker.RawResults{}
}
// Copyright 2021 OpenSSF 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 raw
import (
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/h2non/filetype"
"github.com/h2non/filetype/types"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
// how many bytes are considered when determining if a file is text or binary.
const binaryTestLen = 1024
// BinaryArtifacts retrieves the raw data for the Binary-Artifacts check.
func BinaryArtifacts(req *checker.CheckRequest) (checker.BinaryArtifactData, error) {
c := req.RepoClient
files := []checker.File{}
err := fileparser.OnMatchingFileReaderDo(c, fileparser.PathMatcher{
Pattern: "*",
CaseSensitive: false,
}, checkBinaryFileReader, &files)
if err != nil {
return checker.BinaryArtifactData{}, fmt.Errorf("%w", err)
}
// Ignore validated gradle-wrapper.jar files if present
files, err = excludeValidatedGradleWrappers(c, files)
if err != nil {
return checker.BinaryArtifactData{}, fmt.Errorf("%w", err)
}
// No error, return the files.
return checker.BinaryArtifactData{Files: files}, nil
}
// excludeValidatedGradleWrappers returns the subset of files not confirmed
// to be Action-validated gradle-wrapper.jar files.
func excludeValidatedGradleWrappers(c clients.RepoClient, files []checker.File) ([]checker.File, error) {
// Check if gradle-wrapper.jar present
if !fileExists(files, "gradle-wrapper.jar") {
return files, nil
}
// Gradle wrapper JARs present, so check that they are validated
ok, err := gradleWrapperValidated(c)
if err != nil {
return files, fmt.Errorf(
"failure checking for Gradle wrapper validating Action: %w", err)
}
if !ok {
// Gradle Wrappers not validated
return files, nil
}
// It has been confirmed that latest commit has validated JARs!
// Remove Gradle wrapper JARs from files.
for i := range files {
if filepath.Base(files[i].Path) == "gradle-wrapper.jar" {
files[i].Type = finding.FileTypeBinaryVerified
}
}
return files, nil
}
var checkBinaryFileReader fileparser.DoWhileTrueOnFileReader = func(path string, reader io.Reader,
args ...interface{},
) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf(
"checkBinaryFileReader requires exactly one argument: %w", errInvalidArgLength)
}
pfiles, ok := args[0].(*[]checker.File)
if !ok {
return false, fmt.Errorf(
"checkBinaryFileReader requires argument of type *[]checker.File: %w", errInvalidArgType)
}
binaryFileTypes := map[string]bool{
"crx": true,
"deb": true,
"dex": true,
"dey": true,
"elf": true,
"o": true,
"a": true,
"so": true,
"macho": true,
"iso": true,
"class": true,
"jar": true,
"bundle": true,
"dylib": true,
"lib": true,
"msi": true,
"dll": true,
"drv": true,
"efi": true,
"exe": true,
"ocx": true,
"pyc": true,
"pyo": true,
"par": true,
"rpm": true,
"wasm": true,
"whl": true,
}
content, err := io.ReadAll(io.LimitReader(reader, binaryTestLen))
if err != nil {
return false, fmt.Errorf("reading file: %w", err)
}
var t types.Type
if len(content) == 0 {
return true, nil
}
if t, err = filetype.Get(content); err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("filetype.Get:%v", err))
}
exists1 := binaryFileTypes[t.Extension]
if exists1 {
*pfiles = append(*pfiles, checker.File{
Path: path,
Type: finding.FileTypeBinary,
Offset: checker.OffsetDefault,
})
return true, nil
}
exists2 := binaryFileTypes[strings.ReplaceAll(filepath.Ext(path), ".", "")]
if !isText(content) && exists2 {
*pfiles = append(*pfiles, checker.File{
Path: path,
Type: finding.FileTypeBinary,
Offset: checker.OffsetDefault,
})
}
return true, nil
}
// determines if the first binaryTestLen bytes are text
//
// A version of golang.org/x/tools/godoc/util modified to allow carriage returns
// and utf8.RuneError (0xFFFD), as the file may not be utf8 encoded.
func isText(s []byte) bool {
const maxLen = binaryTestLen // at least utf8.UTFMax (4)
if len(s) > maxLen {
s = s[0:maxLen]
}
for i, c := range string(s) {
if i+utf8.UTFMax > len(s) {
// last char may be incomplete - ignore
break
}
if c < ' ' && c != '\n' && c != '\t' && c != '\r' {
// control character - not a text file
return false
}
}
return true
}
// gradleWrapperValidated checks for the gradle-wrapper-verify action being
// used in a non-failing workflow on the latest commit.
func gradleWrapperValidated(c clients.RepoClient) (bool, error) {
gradleWrapperValidatingWorkflowFile := ""
err := fileparser.OnMatchingFileContentDo(c, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: false,
}, checkWorkflowValidatesGradleWrapper, &gradleWrapperValidatingWorkflowFile)
if err != nil {
return false, fmt.Errorf("%w", err)
}
// no matching files, validation failed
if gradleWrapperValidatingWorkflowFile == "" {
return false, nil
}
// If validated, check that latest commit has a relevant successful run
runs, err := c.ListSuccessfulWorkflowRuns(gradleWrapperValidatingWorkflowFile)
if err != nil {
// some clients, such as the local file client, don't support this feature
// claim unvalidated, so that other parts of the check can still be used.
if errors.Is(err, clients.ErrUnsupportedFeature) {
return false, nil
}
return false, fmt.Errorf("failure listing workflow runs: %w", err)
}
commits, err := c.ListCommits()
if err != nil {
return false, fmt.Errorf("failure listing commits: %w", err)
}
if len(commits) < 1 || len(runs) < 1 {
return false, nil
}
for _, r := range runs {
if *r.HeadSHA == commits[0].SHA {
// Commit has corresponding successful run!
return true, nil
}
}
return false, nil
}
// checkWorkflowValidatesGradleWrapper checks that the current workflow file
// is indeed using the gradle/wrapper-validation-action action, else continues.
func checkWorkflowValidatesGradleWrapper(path string, content []byte, args ...interface{}) (bool, error) {
validatingWorkflowFile, ok := args[0].(*string)
if !ok {
return false, fmt.Errorf("checkWorkflowValidatesGradleWrapper expects arg[0] of type *string: %w", errInvalidArgType)
}
action, errs := actionlint.Parse(content)
if len(errs) > 0 || action == nil {
// Parse fail, so not this file.
return true, nil
}
for _, j := range action.Jobs {
for _, s := range j.Steps {
ea, ok := s.Exec.(*actionlint.ExecAction)
if !ok {
continue
}
if ea.Uses == nil {
continue
}
if strings.HasPrefix(ea.Uses.Value, "gradle/wrapper-validation-action@") ||
strings.HasPrefix(ea.Uses.Value, "gradle/actions/wrapper-validation@") {
// OK! This is it.
*validatingWorkflowFile = filepath.Base(path)
return false, nil
}
}
}
return true, nil
}
// fileExists checks if a file named `name` exists, including within
// subdirectories.
func fileExists(files []checker.File, name string) bool {
for _, f := range files {
if filepath.Base(f.Path) == name {
return true
}
}
return false
}
// Copyright 2021 OpenSSF 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 raw
import (
"errors"
"fmt"
"reflect"
"regexp"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
)
const master = "master"
var commit = regexp.MustCompile("^[a-f0-9]{40}$")
type branchSet struct {
exists map[string]bool
set []clients.BranchRef
}
func (set *branchSet) add(branch *clients.BranchRef) bool {
if branch != nil &&
branch.Name != nil &&
*branch.Name != "" &&
!set.exists[*branch.Name] {
set.set = append(set.set, *branch)
set.exists[*branch.Name] = true
return true
}
return false
}
func (set *branchSet) contains(branch string) bool {
_, contains := set.exists[branch]
return contains
}
// BranchProtection retrieves the raw data for the Branch-Protection check.
func BranchProtection(cr *checker.CheckRequest) (checker.BranchProtectionsData, error) {
c := cr.RepoClient
branches := branchSet{
exists: make(map[string]bool),
}
// Add default branch.
defaultBranch, err := c.GetDefaultBranch()
if err != nil {
return checker.BranchProtectionsData{}, fmt.Errorf("%w", err)
}
branches.add(defaultBranch)
// Get release branches.
releases, err := c.ListReleases()
if err != nil && !errors.Is(err, clients.ErrUnsupportedFeature) {
return checker.BranchProtectionsData{}, fmt.Errorf("%w", err)
}
for _, release := range releases {
if release.TargetCommitish == "" {
// Log with a named error if target_commitish is nil.
return checker.BranchProtectionsData{}, fmt.Errorf("%w", errInternalCommitishNil)
}
// TODO: if this is a sha, get the associated branch. for now, ignore.
if commit.MatchString(release.TargetCommitish) {
continue
}
if branches.contains(release.TargetCommitish) ||
branches.contains(branchRedirect(release.TargetCommitish)) {
continue
}
// Get the associated release branch.
branchRef, err := c.GetBranch(release.TargetCommitish)
if err != nil {
return checker.BranchProtectionsData{},
fmt.Errorf("error during GetBranch(%s): %w", release.TargetCommitish, err)
}
if branches.add(branchRef) {
continue
}
// Couldn't find the branch check for redirects.
redirectBranch := branchRedirect(release.TargetCommitish)
if redirectBranch == "" {
continue
}
branchRef, err = c.GetBranch(redirectBranch)
if err != nil {
return checker.BranchProtectionsData{},
fmt.Errorf("error during GetBranch(%s) %w", redirectBranch, err)
}
branches.add(branchRef)
// Branch doesn't exist or was deleted. Continue.
}
codeownersFiles := []string{}
if err := collectCodeownersFiles(c, &codeownersFiles); err != nil {
return checker.BranchProtectionsData{}, err
}
// No error, return the data.
return checker.BranchProtectionsData{
Branches: branches.set,
CodeownersFiles: codeownersFiles,
}, nil
}
func collectCodeownersFiles(c clients.RepoClient, codeownersFiles *[]string) error {
return fileparser.OnMatchingFileContentDo(c, fileparser.PathMatcher{
Pattern: "CODEOWNERS",
CaseSensitive: true,
}, addCodeownersFile, codeownersFiles)
}
var addCodeownersFile fileparser.DoWhileTrueOnFileContent = func(
path string,
content []byte,
args ...interface{},
) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf(
"addCodeownersFile requires exactly 1 arguments: got %v: %w",
len(args), errInvalidArgLength)
}
codeownersFiles := dataAsStringSlicePtr(args[0])
*codeownersFiles = append(*codeownersFiles, path)
return true, nil
}
func dataAsStringSlicePtr(data interface{}) *[]string {
pdata, ok := data.(*[]string)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type *[]string, got %v", reflect.TypeOf(data)))
}
return pdata
}
func branchRedirect(name string) string {
// Ideally, we should check using repositories.GetBranch if there was a branch redirect.
// See https://github.com/google/go-github/issues/1895
// For now, handle the common master -> main redirect.
if name == master {
return "main"
}
return ""
}
// Copyright 2022 OpenSSF 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 raw
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
func CITests(c clients.RepoClient) (checker.CITestData, error) {
commits, err := c.ListCommits()
if err != nil {
e := sce.WithMessage(
sce.ErrScorecardInternal,
fmt.Sprintf("RepoClient.ListCommits: %v", err),
)
return checker.CITestData{}, e
}
runs := make(map[string][]clients.CheckRun)
commitStatuses := make(map[string][]clients.Status)
prNos := make(map[string]int)
for i := range commits {
pr := &commits[i].AssociatedMergeRequest
if pr.MergedAt.IsZero() {
continue
}
prNos[pr.HeadSHA] = pr.Number
// HeadSHA is the last commit before the merge. if squashing enabled,
// multiple commit SHAs will map to a single HeadSHA
if len(runs[pr.HeadSHA]) == 0 {
crs, err := c.ListCheckRunsForRef(pr.HeadSHA)
if err != nil {
return checker.CITestData{}, sce.WithMessage(
sce.ErrScorecardInternal,
fmt.Sprintf("Client.Repositories.ListCheckRunsForRef: %v", err),
)
}
runs[pr.HeadSHA] = crs
}
statuses, err := c.ListStatuses(pr.HeadSHA)
if err != nil {
return checker.CITestData{}, sce.WithMessage(
sce.ErrScorecardInternal,
fmt.Sprintf("Client.Repositories.ListStatuses: %v", err),
)
}
commitStatuses[pr.HeadSHA] = append(commitStatuses[pr.HeadSHA], statuses...)
}
// Collate
infos := []checker.RevisionCIInfo{}
for headsha := range runs {
crs := runs[headsha]
statuses := commitStatuses[headsha]
infos = append(infos, checker.RevisionCIInfo{
HeadSHA: headsha,
CheckRuns: crs,
Statuses: statuses,
PullRequestNumber: prNos[headsha],
})
}
return checker.CITestData{CIInfo: infos}, nil
}
// Copyright 2022 OpenSSF 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 raw
import (
"errors"
"fmt"
"github.com/ossf/scorecard/v5/checker"
)
var errEmptyClient = errors.New("CII client is nil")
// CIIBestPractices retrieves the raw data for the CIIBestPractices check.
func CIIBestPractices(c *checker.CheckRequest) (checker.CIIBestPracticesData, error) {
var results checker.CIIBestPracticesData
if c.CIIClient == nil {
return results, fmt.Errorf("%w", errEmptyClient)
}
badge, err := c.CIIClient.GetBadgeLevel(c.Ctx, c.Repo.URI())
if err != nil {
return results, fmt.Errorf("%w", err)
}
results.Badge = badge
return results, nil
}
// Copyright 2020 OpenSSF 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 raw
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
)
var (
rePhabricatorRevID = regexp.MustCompile(`Differential Revision:[^\r\n]*(D\d+)`)
rePiperRevID = regexp.MustCompile(`PiperOrigin-RevId:\s*(\d{3,})`)
)
// CodeReview retrieves the raw data for the Code-Review check.
func CodeReview(c clients.RepoClient) (checker.CodeReviewData, error) {
// Look at the latest commits.
commits, err := c.ListCommits()
if err != nil {
return checker.CodeReviewData{}, fmt.Errorf("%w", err)
}
changesets := getChangesets(commits)
return checker.CodeReviewData{
DefaultBranchChangesets: changesets,
}, nil
}
func getGithubRevisionID(c *clients.Commit) string {
mr := c.AssociatedMergeRequest
if !c.AssociatedMergeRequest.MergedAt.IsZero() && mr.Number != 0 {
return strconv.Itoa(mr.Number)
}
return ""
}
func getGithubReviews(c *clients.Commit) (reviews []clients.Review) {
reviews = []clients.Review{}
reviews = append(reviews, c.AssociatedMergeRequest.Reviews...)
if !c.AssociatedMergeRequest.MergedAt.IsZero() {
reviews = append(reviews, clients.Review{Author: &c.AssociatedMergeRequest.MergedBy, State: "APPROVED"})
}
return
}
func getGithubAuthor(c *clients.Commit) (author clients.User) {
return c.AssociatedMergeRequest.Author
}
func getProwRevisionID(c *clients.Commit) string {
mr := c.AssociatedMergeRequest
if !c.AssociatedMergeRequest.MergedAt.IsZero() {
for _, l := range c.AssociatedMergeRequest.Labels {
if l.Name == "lgtm" || l.Name == "approved" && mr.Number != 0 {
return strconv.Itoa(mr.Number)
}
}
}
return ""
}
func getGerritRevisionID(c *clients.Commit) string {
m := c.Message
if strings.Contains(m, "Reviewed-on:") &&
strings.Contains(m, "Reviewed-by:") {
return c.SHA
}
return ""
}
// Given m, a commit message, find the Phabricator revision ID in it.
func getPhabricatorRevisionID(c *clients.Commit) string {
m := c.Message
match := rePhabricatorRevID.FindStringSubmatch(m)
if len(match) < 2 {
return ""
}
return match[1]
}
// Given m, a commit message, find the piper revision ID in it.
func getPiperRevisionID(c *clients.Commit) string {
m := c.Message
match := rePiperRevID.FindStringSubmatch(m)
if len(match) < 2 {
return ""
}
return match[1]
}
type revisionInfo struct {
Platform checker.ReviewPlatform
ID string
}
func detectCommitRevisionInfo(c *clients.Commit) revisionInfo {
if revisionID := getProwRevisionID(c); revisionID != "" {
return revisionInfo{checker.ReviewPlatformProw, revisionID}
}
if revisionID := getGithubRevisionID(c); revisionID != "" {
return revisionInfo{checker.ReviewPlatformGitHub, revisionID}
}
if revisionID := getPhabricatorRevisionID(c); revisionID != "" {
return revisionInfo{checker.ReviewPlatformPhabricator, revisionID}
}
if revisionID := getGerritRevisionID(c); revisionID != "" {
return revisionInfo{checker.ReviewPlatformGerrit, revisionID}
}
if revisionID := getPiperRevisionID(c); revisionID != "" {
return revisionInfo{checker.ReviewPlatformPiper, revisionID}
}
return revisionInfo{checker.ReviewPlatformUnknown, ""}
}
// Group commits by the changeset they belong to
// Commits must be in-order.
func getChangesets(commits []clients.Commit) []checker.Changeset {
changesets := []checker.Changeset{}
if len(commits) == 0 {
return changesets
}
changesetsByRevInfo := make(map[revisionInfo]checker.Changeset)
for i := range commits {
rev := detectCommitRevisionInfo(&commits[i])
if rev.ID == "" {
rev.ID = commits[i].SHA
}
if changeset, ok := changesetsByRevInfo[rev]; !ok {
newChangeset := checker.Changeset{
ReviewPlatform: rev.Platform,
RevisionID: rev.ID,
Commits: []clients.Commit{commits[i]},
}
if rev.Platform == checker.ReviewPlatformGitHub {
newChangeset.Reviews = getGithubReviews(&commits[i])
newChangeset.Author = getGithubAuthor(&commits[i])
}
changesetsByRevInfo[rev] = newChangeset
} else {
// Part of a previously found changeset.
changeset.Commits = append(changeset.Commits, commits[i])
changesetsByRevInfo[rev] = changeset
}
}
// Changesets are returned in map order (i.e. randomized)
for ri := range changesetsByRevInfo {
changesets = append(changesets, changesetsByRevInfo[ri])
}
return changesets
}
// Copyright 2020 OpenSSF 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 raw
import (
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
)
// Contributors retrieves the raw data for the Contributors check.
func Contributors(cr *checker.CheckRequest) (checker.ContributorsData, error) {
c := cr.RepoClient
var users []clients.User
contribs, err := c.ListContributors()
if err != nil {
return checker.ContributorsData{}, fmt.Errorf("Client.Repositories.ListContributors: %w", err)
}
for _, contrib := range contribs {
user := clients.User{
Login: contrib.Login,
NumContributions: contrib.NumContributions,
IsCodeOwner: contrib.IsCodeOwner,
}
for _, org := range contrib.Organizations {
if org.Login != "" && !orgContains(user.Organizations, org.Login) {
user.Organizations = append(user.Organizations, org)
}
}
for _, company := range contrib.Companies {
if company == "" {
continue
}
company = strings.ToLower(company)
company = strings.ReplaceAll(company, "inc.", "")
company = strings.ReplaceAll(company, "llc", "")
company = strings.ReplaceAll(company, ",", "")
company = strings.TrimLeft(company, "@")
company = strings.Trim(company, " ")
if company != "" && !companyContains(user.Companies, company) {
user.Companies = append(user.Companies, company)
}
}
users = append(users, user)
}
return checker.ContributorsData{Users: users}, nil
}
func companyContains(cs []string, name string) bool {
for _, a := range cs {
if a == name {
return true
}
}
return false
}
func orgContains(os []clients.User, login string) bool {
for _, a := range os {
if a.Login == login {
return true
}
}
return false
}
// Copyright 2021 OpenSSF 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 raw
import (
"fmt"
"regexp"
"strings"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
func containsUntrustedContextPattern(variable string) bool {
// GitHub event context details that may be attacker controlled.
// See https://securitylab.github.com/research/github-actions-untrusted-input/
untrustedContextPattern := regexp.MustCompile(
`.*(issue\.title|` +
`issue\.body|` +
`pull_request\.title|` +
`pull_request\.body|` +
`comment\.body|` +
`review\.body|` +
`review_comment\.body|` +
`pages.*\.page_name|` +
`commits.*\.message|` +
`head_commit\.message|` +
`head_commit\.author\.email|` +
`head_commit\.author\.name|` +
`commits.*\.author\.email|` +
`commits.*\.author\.name|` +
`pull_request\.head\.ref|` +
`pull_request\.head\.label|` +
`pull_request\.head\.repo\.default_branch).*`)
if strings.Contains(variable, "github.head_ref") {
return true
}
return strings.Contains(variable, "github.event.") && untrustedContextPattern.MatchString(variable)
}
type triggerName string
var (
triggerPullRequestTarget = triggerName("pull_request_target")
triggerWorkflowRun = triggerName("workflow_run")
checkoutUntrustedPullRequestRef = "github.event.pull_request"
checkoutUntrustedWorkflowRunRef = "github.event.workflow_run"
)
// DangerousWorkflow retrieves the raw data for the DangerousWorkflow check.
func DangerousWorkflow(c *checker.CheckRequest) (checker.DangerousWorkflowData, error) {
// data is shared across all GitHub workflows.
var data checker.DangerousWorkflowData
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: false,
}, validateGitHubActionWorkflowPatterns, &data)
return data, err
}
// Check file content.
var validateGitHubActionWorkflowPatterns fileparser.DoWhileTrueOnFileContent = func(path string,
content []byte,
args ...interface{},
) (bool, error) {
if !fileparser.IsWorkflowFile(path) {
return true, nil
}
if len(args) != 1 {
return false, fmt.Errorf(
"validateGitHubActionWorkflowPatterns requires exactly 2 arguments: %w", errInvalidArgLength)
}
// Verify the type of the data.
pdata, ok := args[0].(*checker.DangerousWorkflowData)
if !ok {
return false, fmt.Errorf(
"validateGitHubActionWorkflowPatterns expects arg[0] of type *patternCbData: %w", errInvalidArgType)
}
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
pdata.NumWorkflows += 1
workflow, errs := actionlint.Parse(content)
if len(errs) > 0 && workflow == nil {
return false, fileparser.FormatActionlintError(errs)
}
// 1. Check for untrusted code checkout with pull_request_target and a ref
if err := validateUntrustedCodeCheckout(workflow, path, pdata); err != nil {
return false, err
}
// 2. Check for script injection in workflow inline scripts.
if err := validateScriptInjection(workflow, path, pdata); err != nil {
return false, err
}
// TODO: Check other dangerous patterns.
return true, nil
}
func validateUntrustedCodeCheckout(workflow *actionlint.Workflow, path string,
pdata *checker.DangerousWorkflowData,
) error {
if !usesEventTrigger(workflow, triggerPullRequestTarget) && !usesEventTrigger(workflow, triggerWorkflowRun) {
return nil
}
for _, job := range workflow.Jobs {
if err := checkJobForUntrustedCodeCheckout(job, path, pdata); err != nil {
return err
}
}
return nil
}
func usesEventTrigger(workflow *actionlint.Workflow, name triggerName) bool {
// Check if the webhook event trigger is a pull_request_target
for _, event := range workflow.On {
if event.EventName() == string(name) {
return true
}
}
return false
}
func createJob(job *actionlint.Job) *checker.WorkflowJob {
if job == nil {
return nil
}
var r checker.WorkflowJob
if job.Name != nil {
r.Name = &job.Name.Value
}
if job.ID != nil {
r.ID = &job.ID.Value
}
return &r
}
func checkJobForUntrustedCodeCheckout(job *actionlint.Job, path string,
pdata *checker.DangerousWorkflowData,
) error {
if job == nil {
return nil
}
// Check each step, which is a map, for checkouts with untrusted ref
for _, step := range job.Steps {
if step == nil || step.Exec == nil {
continue
}
// Check for a step that uses actions/checkout
e, ok := step.Exec.(*actionlint.ExecAction)
if !ok || e.Uses == nil {
continue
}
if !strings.Contains(e.Uses.Value, "actions/checkout") {
continue
}
// Check for reference. If not defined for a pull_request_target event, this defaults to
// the base branch of the pull request.
ref, ok := e.Inputs["ref"]
if !ok || ref.Value == nil {
continue
}
if strings.Contains(ref.Value.Value, checkoutUntrustedPullRequestRef) ||
strings.Contains(ref.Value.Value, checkoutUntrustedWorkflowRunRef) {
line := fileparser.GetLineNumber(step.Pos)
pdata.Workflows = append(pdata.Workflows,
checker.DangerousWorkflow{
Type: checker.DangerousWorkflowUntrustedCheckout,
File: checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: line,
Snippet: ref.Value.Value,
},
Job: createJob(job),
},
)
}
}
return nil
}
func validateScriptInjection(workflow *actionlint.Workflow, path string,
pdata *checker.DangerousWorkflowData,
) error {
for _, job := range workflow.Jobs {
if job == nil {
continue
}
for _, step := range job.Steps {
if step == nil {
continue
}
run, ok := step.Exec.(*actionlint.ExecRun)
if !ok || run.Run == nil {
continue
}
// Check Run *String for user-controllable (untrustworthy) properties.
if err := checkVariablesInScript(run.Run.Value, run.Run.Pos, job, path, pdata); err != nil {
return err
}
}
}
return nil
}
func checkVariablesInScript(script string, pos *actionlint.Pos,
job *actionlint.Job, path string,
pdata *checker.DangerousWorkflowData,
) error {
for {
s := strings.Index(script, "${{")
if s == -1 {
break
}
e := strings.Index(script[s:], "}}")
if e == -1 {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
// Check if the variable may be untrustworthy.
variable := script[s+3 : s+e]
if containsUntrustedContextPattern(variable) {
line := fileparser.GetLineNumber(pos)
pdata.Workflows = append(pdata.Workflows,
checker.DangerousWorkflow{
File: checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: line,
Snippet: variable,
},
Job: createJob(job),
Type: checker.DangerousWorkflowScriptInjection,
},
)
}
script = script[s+e:]
}
return nil
}
// Copyright 2020 OpenSSF 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 raw
import (
"errors"
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
)
const (
dependabotID = 49699333
)
// DependencyUpdateTool is the exported name for Dependency-Update-Tool.
func DependencyUpdateTool(c clients.RepoClient) (checker.DependencyUpdateToolData, error) {
var tools []checker.Tool
err := fileparser.OnAllFilesDo(c, checkDependencyFileExists, &tools)
if err != nil {
return checker.DependencyUpdateToolData{}, fmt.Errorf("%w", err)
}
if len(tools) != 0 {
return checker.DependencyUpdateToolData{Tools: tools}, nil
}
commits, err := c.SearchCommits(clients.SearchCommitsOptions{Author: "dependabot[bot]"})
if err != nil {
// TODO https://github.com/ossf/scorecard/issues/1709
// some repo clients (e.g. local) don't currently have the ability to search commits,
// but some data is better than none.
if errors.Is(err, clients.ErrUnsupportedFeature) {
return checker.DependencyUpdateToolData{Tools: tools}, nil
}
return checker.DependencyUpdateToolData{}, fmt.Errorf("dependabot commit search: %w", err)
}
for i := range commits {
if commits[i].Committer.ID == dependabotID {
tools = append(tools, checker.Tool{
Name: "Dependabot",
URL: asPointer("https://github.com/dependabot"),
Desc: asPointer("Automated dependency updates built into GitHub"),
Files: []checker.File{{}},
})
break
}
}
return checker.DependencyUpdateToolData{Tools: tools}, nil
}
var checkDependencyFileExists fileparser.DoWhileTrueOnFilename = func(name string, args ...interface{}) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf("checkDependencyFileExists requires exactly one argument: %w", errInvalidArgLength)
}
ptools, ok := args[0].(*[]checker.Tool)
if !ok {
return false, fmt.Errorf(
"checkDependencyFileExists requires an argument of type: *[]checker.Tool: %w", errInvalidArgType)
}
switch strings.ToLower(name) {
case ".github/dependabot.yml", ".github/dependabot.yaml":
*ptools = append(*ptools, checker.Tool{
Name: "Dependabot",
URL: asPointer("https://github.com/dependabot"),
Desc: asPointer("Automated dependency updates built into GitHub"),
Files: []checker.File{
{
Path: name,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
},
})
// https://docs.renovatebot.com/configuration-options/
case "renovate.json",
"renovate.json5",
".github/renovate.json",
".github/renovate.json5",
".gitlab/renovate.json",
".gitlab/renovate.json5",
".renovaterc",
".renovaterc.json",
".renovaterc.json5":
*ptools = append(*ptools, checker.Tool{
Name: "RenovateBot",
URL: asPointer("https://github.com/renovatebot/renovate"),
Desc: asPointer("Automated dependency updates. Multi-platform and multi-language."),
Files: []checker.File{
{
Path: name,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
},
})
case ".pyup.yml":
*ptools = append(*ptools, checker.Tool{
Name: "PyUp",
URL: asPointer("https://pyup.io/"),
Desc: asPointer("Automated dependency updates for Python."),
Files: []checker.File{
{
Path: name,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
},
})
// https://github.com/scala-steward-org/scala-steward/blob/main/docs/repo-specific-configuration.md
case ".scala-steward.conf",
"scala-steward.conf",
".github/.scala-steward.conf",
".github/scala-steward.conf",
".config/.scala-steward.conf",
".config/scala-steward.conf":
*ptools = append(*ptools, checker.Tool{
Name: "scala-steward",
URL: asPointer("https://github.com/scala-steward-org/scala-steward"),
Desc: asPointer("Works with Maven, Mill, sbt, and Scala CLI."),
Files: []checker.File{
{
Path: name,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
},
})
}
// Continue iterating, even if we have found a tool.
// It's needed for all probes results to be populated.
return true, nil
}
func asPointer(s string) *string {
return &s
}
func asBoolPointer(b bool) *bool {
return &b
}
// Copyright 2021 OpenSSF 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 raw
import (
"bytes"
"fmt"
"regexp"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/fuzzers"
)
type filesWithPatternStr struct {
pattern string
files []checker.File
}
// Configurations for language-specified fuzzers.
type languageFuzzConfig struct {
URL, Desc *string
funcPattern, Name string
// TODO: add more language fuzzing-related fields.
// Patterns are according to path.Match.
filePatterns []string
}
// Contains fuzzing specifications for programming languages.
// Please use the type Language defined in clients/languages.go rather than a raw string.
var languageFuzzSpecs = map[clients.LanguageName]languageFuzzConfig{
// Default fuzz patterns for Go.
clients.Go: {
filePatterns: []string{"*_test.go"},
funcPattern: `func\s+Fuzz\w+\s*\(\w+\s+\*testing.F\)`,
Name: fuzzers.BuiltInGo,
URL: asPointer("https://go.dev/doc/fuzz/"),
Desc: asPointer(
"Go fuzzing intelligently walks through the source code to report failures and find vulnerabilities."),
},
// Fuzz patterns for Erlang based on property-based testing.
clients.Erlang: {
filePatterns: []string{"*.erl", "*.hrl"},
// Look for direct imports of QuickCheck or Proper,
funcPattern: `-include_lib\("(eqc|proper)/include/(eqc|proper).hrl"\)\.`,
Name: fuzzers.PropertyBasedErlang,
Desc: propertyBasedDescription("Erlang"),
},
// Fuzz patterns for Haskell based on property-based testing.
//
// Based on the import of one of these packages:
// * https://hackage.haskell.org/package/QuickCheck
// * https://hedgehog.qa/
// * https://github.com/NorfairKing/validity
// * https://hackage.haskell.org/package/smallcheck
//
// They can also be imported indirectly through these test frameworks:
// * https://hspec.github.io/
// * https://hackage.haskell.org/package/tasty
//
// This is not an exhaustive list.
clients.Haskell: {
filePatterns: []string{"*.hs", "*.lhs"},
// Look for direct imports of QuickCheck, Hedgehog, validity, or SmallCheck,
// or their indirect imports through the higher-level Hspec or Tasty testing frameworks.
funcPattern: `import\s+(qualified\s+)?Test\.((Hspec|Tasty)\.)?(QuickCheck|Hedgehog|Validity|SmallCheck)`,
Name: fuzzers.PropertyBasedHaskell,
Desc: propertyBasedDescription("Haskell"),
},
// Fuzz patterns for Elixir based on property-based testing.
clients.Elixir: {
filePatterns: []string{"*.ex", "*.exs"},
// Look for direct imports of PropCheck, and StreamData.
funcPattern: `use\s+(PropCheck|ExUnitProperties)`,
Name: fuzzers.PropertyBasedElixir,
Desc: propertyBasedDescription("Elixir"),
},
// Fuzz patterns for Gleam based on property-based testing.
clients.Gleam: {
filePatterns: []string{"*.gleam"},
// Look for direct imports of PropCheck, and StreamData.
funcPattern: `import\s+qcheck`, // Gleam library
Name: fuzzers.PropertyBasedGleam,
Desc: propertyBasedDescription("Gleam"),
},
// Fuzz patterns for JavaScript and TypeScript based on property-based testing.
//
// Based on the import of one of these packages:
// * https://github.com/dubzzz/fast-check/tree/main/packages/fast-check#readme
// * https://github.com/dubzzz/fast-check/tree/main/packages/ava#readme
// * https://github.com/dubzzz/fast-check/tree/main/packages/jest#readme
// * https://github.com/dubzzz/fast-check/tree/main/packages/vitest#readme
//
// This is not an exhaustive list.
clients.JavaScript: {
filePatterns: []string{"*.js", "*.jsx"},
// Look for direct imports of fast-check and its test runners integrations.
funcPattern: `(from\s+['"](fast-check|@fast-check/(ava|jest|vitest))['"]|` +
`require\(\s*['"](fast-check|@fast-check/(ava|jest|vitest))['"]\s*\))`,
Name: fuzzers.PropertyBasedJavaScript,
Desc: propertyBasedDescription("JavaScript"),
},
clients.TypeScript: {
filePatterns: []string{"*.ts", "*.tsx"},
// Look for direct imports of fast-check and its test runners integrations.
funcPattern: `(from\s+['"](fast-check|@fast-check/(ava|jest|vitest))['"]|` +
`require\(\s*['"](fast-check|@fast-check/(ava|jest|vitest))['"]\s*\))`,
Name: fuzzers.PropertyBasedTypeScript,
Desc: propertyBasedDescription("TypeScript"),
},
clients.Python: {
filePatterns: []string{"*.py"},
funcPattern: `import atheris`,
Name: fuzzers.PythonAtheris,
Desc: asPointer(
"Python fuzzing by way of Atheris"),
},
clients.C: {
filePatterns: []string{"*.c"},
funcPattern: `LLVMFuzzerTestOneInput`,
Name: fuzzers.CLibFuzzer,
Desc: asPointer(
"Fuzzed with C LibFuzzer"),
},
clients.Cpp: {
filePatterns: []string{"*.cc", "*.cpp"},
funcPattern: `LLVMFuzzerTestOneInput`,
Name: fuzzers.CppLibFuzzer,
Desc: asPointer(
"Fuzzed with cpp LibFuzzer"),
},
clients.Rust: {
filePatterns: []string{"*.rs"},
funcPattern: `libfuzzer_sys`,
Name: fuzzers.RustCargoFuzz,
Desc: asPointer(
"Fuzzed with Cargo-fuzz"),
},
clients.Java: {
filePatterns: []string{"*.java"},
funcPattern: `com.code_intelligence.jazzer.api.FuzzedDataProvider;`,
Name: fuzzers.JavaJazzerFuzzer,
Desc: asPointer(
"Fuzzed with Jazzer fuzzer"),
},
clients.Swift: {
filePatterns: []string{"*.swift"},
funcPattern: `LLVMFuzzerTestOneInput`,
Name: fuzzers.SwiftLibFuzzer,
Desc: asPointer(
"Fuzzed with Swift LibFuzzer"),
},
// TODO: add more language-specific fuzz patterns & configs.
}
// Fuzzing runs Fuzzing check.
func Fuzzing(c *checker.CheckRequest) (checker.FuzzingData, error) {
var detectedFuzzers []checker.Tool
usingCFLite, e := checkCFLite(c)
if e != nil {
return checker.FuzzingData{}, fmt.Errorf("%w", e)
}
if usingCFLite {
detectedFuzzers = append(detectedFuzzers,
checker.Tool{
Name: fuzzers.ClusterFuzzLite,
URL: asPointer("https://github.com/google/clusterfuzzlite"),
Desc: asPointer("continuous fuzzing solution that runs as part of Continuous Integration (CI) workflows"),
// TODO: File.
},
)
}
usingOSSFuzz, e := checkOSSFuzz(c)
if e != nil {
return checker.FuzzingData{}, fmt.Errorf("%w", e)
}
if usingOSSFuzz {
detectedFuzzers = append(detectedFuzzers,
checker.Tool{
Name: fuzzers.OSSFuzz,
URL: asPointer("https://github.com/google/oss-fuzz"),
Desc: asPointer("Continuous Fuzzing for Open Source Software"),
// TODO: File.
},
)
}
langs, err := c.RepoClient.ListProgrammingLanguages()
if err != nil {
return checker.FuzzingData{}, fmt.Errorf("cannot get langs of repo: %w", err)
}
prominentLangs := getProminentLanguages(langs)
for _, lang := range prominentLangs {
usingFuzzFunc, files, e := checkFuzzFunc(c, lang)
if e != nil {
return checker.FuzzingData{}, fmt.Errorf("%w", e)
}
if usingFuzzFunc {
detectedFuzzers = append(detectedFuzzers,
checker.Tool{
Name: languageFuzzSpecs[lang].Name,
URL: languageFuzzSpecs[lang].URL,
Desc: languageFuzzSpecs[lang].Desc,
Files: files,
},
)
}
}
return checker.FuzzingData{Fuzzers: detectedFuzzers}, nil
}
func checkCFLite(c *checker.CheckRequest) (bool, error) {
result := false
e := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".clusterfuzzlite/Dockerfile",
CaseSensitive: true,
}, func(path string, content []byte, args ...interface{}) (bool, error) {
result = fileparser.CheckFileContainsCommands(content, "#")
return false, nil
}, nil)
if e != nil {
return result, fmt.Errorf("%w", e)
}
return result, nil
}
func checkOSSFuzz(c *checker.CheckRequest) (bool, error) {
if c.OssFuzzRepo == nil {
return false, nil
}
req := clients.SearchRequest{
Query: c.RepoClient.URI(),
Filename: "project.yaml",
}
result, err := c.OssFuzzRepo.Search(req)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Client.Search.Code: %v", err))
return false, e
}
return result.Hits > 0, nil
}
func checkFuzzFunc(c *checker.CheckRequest, lang clients.LanguageName) (bool, []checker.File, error) {
if c.RepoClient == nil {
return false, nil, nil
}
data := filesWithPatternStr{
files: make([]checker.File, 0),
}
// Search language-specified fuzz func patterns in the hashmap.
pattern, found := languageFuzzSpecs[lang]
if !found {
// If the fuzz patterns for the current language not supported yet,
// we return it as false (not found), nil (no files), and nil (no errors).
return false, nil, nil
}
// Get patterns for file and func.
// We use the file pattern in the matcher to match the test files,
// and put the func pattern in var data to match file contents (func names).
filePatterns, funcPattern := pattern.filePatterns, pattern.funcPattern
var dataFiles []checker.File
for _, filePattern := range filePatterns {
matcher := fileparser.PathMatcher{
Pattern: filePattern,
CaseSensitive: false,
}
data.pattern = funcPattern
err := fileparser.OnMatchingFileContentDo(c.RepoClient, matcher, getFuzzFunc, &data)
if err != nil {
return false, nil, fmt.Errorf("error when OnMatchingFileContentDo: %w", err)
}
dataFiles = append(dataFiles, data.files...)
}
if len(dataFiles) == 0 {
// This means no fuzz funcs matched for this language.
return false, nil, nil
}
return true, dataFiles, nil
}
// This is the callback func for interface OnMatchingFileContentDo
// used for matching fuzz functions in the file content,
// and return a list of files (or nil for not found).
var getFuzzFunc fileparser.DoWhileTrueOnFileContent = func(
path string, content []byte, args ...interface{},
) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf("getFuzzFunc requires exactly one argument: %w", errInvalidArgLength)
}
pdata, ok := args[0].(*filesWithPatternStr)
if !ok {
return false, errInvalidArgType
}
r := regexp.MustCompile(pdata.pattern)
lines := bytes.Split(content, []byte("\n"))
for i, line := range lines {
found := r.FindString(string(line))
if found != "" {
// If fuzz func is found in the file, add it to the file array,
// with its file path as Path, func name as Snippet,
// FileTypeFuzz as Type, and # of lines as Offset.
pdata.files = append(pdata.files, checker.File{
Path: path,
Type: finding.FileTypeSource,
Snippet: found,
Offset: uint(i + 1), // Since the # of lines starts from zero.
})
}
}
return true, nil
}
func getProminentLanguages(langs []clients.Language) []clients.LanguageName {
numLangs := len(langs)
if numLangs == 0 {
return nil
} else if len(langs) == 1 && langs[0].Name == clients.All {
return getAllLanguages()
}
totalLoC := 0
// Use a map to record languages and their lines of code to drop potential duplicates.
langMap := map[clients.LanguageName]int{}
for _, l := range langs {
totalLoC += l.NumLines
langMap[l.Name] += l.NumLines
}
// Calculate the average lines of code in the current repo.
// This var can stay as an int, no need for a precise float value.
avgLoC := totalLoC / numLangs
// Languages that have lines of code above average will be considered prominent.
prominentThreshold := avgLoC / 4.0
ret := []clients.LanguageName{}
for lName, loC := range langMap {
if loC >= prominentThreshold {
lang := clients.LanguageName(strings.ToLower(string(lName)))
ret = append(ret, lang)
}
}
return ret
}
func getAllLanguages() []clients.LanguageName {
allLanguages := make([]clients.LanguageName, 0, len(languageFuzzSpecs))
for l := range languageFuzzSpecs {
allLanguages = append(allLanguages, l)
}
return allLanguages
}
func propertyBasedDescription(language string) *string {
s := fmt.Sprintf("Property-based testing in %s generates test instances randomly or exhaustively "+
"and test that specific properties are satisfied.", language)
return &s
}
// Copyright 2020 OpenSSF 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 github
import (
"errors"
"fmt"
"io"
"path/filepath"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
)
// Packaging checks for packages.
func Packaging(c *checker.CheckRequest) (checker.PackagingData, error) {
var data checker.PackagingData
matchedFiles, err := c.RepoClient.ListFiles(fileparser.IsGithubWorkflowFileCb)
if err != nil {
return data, fmt.Errorf("%w", err)
}
if err != nil {
return data, fmt.Errorf("RepoClient.ListFiles: %w", err)
}
for _, fp := range matchedFiles {
fr, err := c.RepoClient.GetFileReader(fp)
if err != nil {
return data, fmt.Errorf("RepoClient.GetFileReader: %w", err)
}
fc, err := io.ReadAll(fr)
fr.Close()
if err != nil {
return data, fmt.Errorf("reading file: %w", err)
}
workflow, errs := actionlint.Parse(fc)
if len(errs) > 0 && workflow == nil {
e := fileparser.FormatActionlintError(errs)
return data, e
}
// Check if it's a packaging workflow.
match, ok := fileparser.IsPackagingWorkflow(workflow, fp)
// Always print debug messages.
data.Packages = append(data.Packages,
checker.Package{
Msg: &match.Msg,
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
},
)
if !ok {
continue
}
runs, err := c.RepoClient.ListSuccessfulWorkflowRuns(filepath.Base(fp))
if err != nil {
// assume the workflow will have run for localdir client
if errors.Is(err, clients.ErrUnsupportedFeature) {
runs = append(runs, clients.WorkflowRun{})
} else {
return data, fmt.Errorf("Client.Actions.ListWorkflowRunsByFileName: %w", err)
}
}
if len(runs) > 0 {
// Create package.
pkg := checker.Package{
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: match.File.Offset,
},
Runs: []checker.Run{
{
URL: runs[0].URL,
},
},
}
// Create runs.
for _, run := range runs {
pkg.Runs = append(pkg.Runs,
checker.Run{
URL: run.URL,
},
)
}
data.Packages = append(data.Packages, pkg)
return data, nil
}
data.Packages = append(data.Packages,
checker.Package{
// Debug message.
Msg: StringPointer(fmt.Sprintf("GitHub publishing workflow not used in runs: %v", fp)),
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
// TODO: Job
},
)
}
// Return raw results.
return data, nil
}
func StringPointer(s string) *string {
return &s
}
// Copyright 2020 OpenSSF 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 gitlab
import (
"fmt"
"io"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/finding"
)
// Packaging checks for packages.
func Packaging(c *checker.CheckRequest) (checker.PackagingData, error) {
var data checker.PackagingData
matchedFiles, err := c.RepoClient.ListFiles(fileparser.IsGitlabWorkflowFile)
if err != nil {
return data, fmt.Errorf("RepoClient.ListFiles: %w", err)
}
for _, fp := range matchedFiles {
fr, err := c.RepoClient.GetFileReader(fp)
if err != nil {
return data, fmt.Errorf("RepoClient.GetFileReader: %w", err)
}
fc, err := io.ReadAll(fr)
fr.Close()
if err != nil {
return data, fmt.Errorf("reading from file: %w", err)
}
file, found := isGitlabPackagingWorkflow(fc, fp)
if found {
data.Packages = append(data.Packages, checker.Package{
Name: new(string),
Job: &checker.WorkflowJob{},
File: &file,
Msg: nil,
Runs: []checker.Run{{URL: c.Repo.URI()}},
})
return data, nil
}
}
return data, nil
}
func StringPointer(s string) *string {
return &s
}
func isGitlabPackagingWorkflow(fc []byte, fp string) (checker.File, bool) {
lineNumber := checker.OffsetDefault
packagingStrings := []string{
"docker push",
"nuget push",
"poetry publish",
"twine upload",
}
ParseLines:
for idx, val := range strings.Split(string(fc), "\n") {
for _, element := range packagingStrings {
if strings.Contains(val, element) {
lineNumber = uint(idx + 1)
break ParseLines
}
}
}
return checker.File{
Path: fp,
Offset: lineNumber,
Type: finding.FileTypeSource,
}, lineNumber != checker.OffsetDefault
}
// Copyright 2021 OpenSSF 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 raw
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
)
// from checks.md
// - files must be at the top-level directory (hence the '^' in the regex).
// - files must be of the name like COPY[ING|RIGHT] or LICEN[SC]E(plural)
// no preceding names or the like (again, hence the '^').
// - a folder is also acceptable as in COPYRIGHT/ or LICENSES/.
//
// TODO: although the contents of the folder are totally ignored at this time.
// TODO: file or folder the contents are not (yet) examined.
// - it is a case insenstive check (hence the leading regex compiler option).
var reLicenseFile = regexp.MustCompile(
// generalized pattern matching to detect a file named:
// PATENTs
// COPYRIGHTs
// LICENSEs
// where
// the detected file:
// - must be at the top-level directory
// - may be preceded or suffixed by a SPDX Identifier
// - may be preceded or suffixed by a pre- or -suf separator
// - may have a file extension denoted by a leading dot/period
// notes
// 1. when a suffix of '/' for the detected file is found (a folder)
// the SPDX Identifier will be sensed from the first filename found
// (e.g., LICENSE/GPL-3.0.md will set SPDX Identifier as 'GPL-3.0')
// warning: retrieval of filenames is non-deterministic.
// TODO: return a list of possible SPDX Identifiers from a folder
// 2. an SPDX ID is loosely pattern matched by a sequence of alpha
// numerics followed by a separator (hyphen, dot, or underscore)
// followed by some form of a version number (major, minor, but
// no patch--with each number being no more than 2 digits) where
// that version number may be proceeded by a letter.
// 3. the function 'validateSpdxIDAndExt()' in this check works
// to disambiguate SPDX Identifiers and allowed file extensions
// that cannot be cleanly (or regularly) discerned by regex,
// that is why this is a generalized regex pattern.
`(?i)` + // case insensitive
`^(?P<lp>` + // must be at the top level (i.e., no leading dot/period or path separator)
// (opt) preceded SPDX ID (e.g., 'GPL-2.0' as in GPL-2.0-LICENCE)
`(?P<preSpdx>([0-9A-Za-z]+)((([-_.])[[:digit:]]{1,2}[A-Za-z]{0,1}){0,5})(?P<preSpdxExt>(([_-])?[0-9A-Za-z.])*))?` +
// (opt) separator before the detected file (e.g., '-' as in GPL-2.0-LICENCE)
`(?P<pre>([-_]))?` +
// mandatory file names to detect (e.g., 'LICENCE' as in GPL-2.0-LICENCE)
`(?P<detectedFile>(patent(s)?|copy(ing|right)|LICEN[SC]E(S)?))` +
// (opt) separator after the detected file (e.g., '_' as in LICENSE_Apache-1.1)
`(?P<suf>([-_./]))?` +
// (opt) suffixed SPDX ID (e.g., 'Apache-1.1' as in LICENSE_Apache-1.1)
`(?P<sufSpdx>([0-9A-Za-z]+)((([-_.])[[:digit:]]{1,2}[A-Za-z]{0,1}){0,5})(?P<sufSpdxExt>(([_-])?[0-9A-Za-z.])*))?` +
// (opt) file name extension (e.g., '.md' as in LICENSES.md)
`(?P<ext>([.]?[A-Za-z]+))?` +
`)` +
``,
)
var reGroupNames = reLicenseFile.SubexpNames()
// from checks.md
// - for the most part extension are ignore, but this cannot be so, many files
// can get caught up in the filename match (like 'license.yaml'), therefore
// the need to accept a host of file extensions which could likely contain
// license information.
var reLicenseFileExts = regexp.MustCompile(
`(?i)` +
`(` +
`\.adoc|` +
`\.asc|` +
`\.doc(x)?|` +
`\.ext|` +
`\.html|` +
`\.markdown|` +
`\.md|` +
`\.rst|` +
`\.txt|` +
`\.xml` +
`)`,
)
// License retrieves the raw data for the License check.
func License(c *checker.CheckRequest) (checker.LicenseData, error) {
var results checker.LicenseData
// prepare case insensitive map to map approved licenses matched in repo.
setCiMap()
licensesFound, lerr := c.RepoClient.ListLicenses()
switch {
// repo API for licenses is supported
// go the work and return from immediate (no searching repo).
case lerr == nil:
// licenses API may be supported, but platform might not detect license same way we do
// fallback to our local file logic
if len(licensesFound) == 0 {
break
}
for _, v := range licensesFound {
results.LicenseFiles = append(results.LicenseFiles,
checker.LicenseFile{
File: checker.File{
Path: v.Path,
Type: finding.FileTypeSource,
},
LicenseInformation: checker.License{
Approved: len(fsfOsiApprovedLicenseCiMap[strings.ToUpper(v.SPDXId)].Name) > 0,
Name: v.Name,
SpdxID: v.SPDXId,
Attribution: checker.LicenseAttributionTypeAPI,
},
})
}
return results, nil
// if repo API for listing licenses is not support
// continue on using the repo search for a license file.
case errors.Is(lerr, clients.ErrUnsupportedFeature):
break
// something else failed, done.
default:
return results, fmt.Errorf("RepoClient.ListLicenses: %w", lerr)
}
// prepare map to index into regex named groups
// only needs to be done once for the non-repo
// license check.
setGroupIdxsMap()
// no repo API for listing licenses, continue looking for files
path := checker.LicenseFile{}
err := fileparser.OnAllFilesDo(c.RepoClient, isLicenseFile, &path)
if err != nil {
return results, fmt.Errorf("fileparser.OnAllFilesDo: %w", err)
}
// scorecard search stops at first candidate (isLicenseFile) license file found
if path != (checker.LicenseFile{}) {
path.LicenseInformation.Name = fsfOsiApprovedLicenseCiMap[strings.ToUpper(path.LicenseInformation.SpdxID)].Name
// these settings (Name, Key) match GH repo API
// for when the Spdx Identifier cannot be determined.
if path.LicenseInformation.SpdxID == "" {
path.LicenseInformation.SpdxID = "NOASSERTION"
path.LicenseInformation.Name = "Other"
}
path.LicenseInformation.Approved = len(
fsfOsiApprovedLicenseCiMap[strings.ToUpper(path.LicenseInformation.SpdxID)].Name) > 0
path.LicenseInformation.Attribution = checker.LicenseAttributionTypeHeuristics
results.LicenseFiles = append(results.LicenseFiles, path)
}
return results, nil
}
// TestLicense used for testing purposes.
func TestLicense(name string) bool {
setCiMap()
setGroupIdxsMap()
_, ok := checkLicense(name)
return ok
}
var isLicenseFile fileparser.DoWhileTrueOnFilename = func(name string, args ...interface{}) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf("isLicenseFile requires exactly one argument: %w", errInvalidArgLength)
}
s, ok := args[0].(*checker.LicenseFile)
if !ok {
return false, fmt.Errorf("isLicenseFile requires argument of type: *checker.LicenseFile: %w", errInvalidArgType)
}
*s, ok = checkLicense(name)
if ok {
return false, nil
}
return true, nil
}
// direct mapping of regex groups by name to index number.
var reGroupIdxs = make(map[string]int)
// case-insensitive map of fsfOsiApprovedLicenseMap.
var fsfOsiApprovedLicenseCiMap = map[string]fsfOsiLicenseType{}
// parallel testing in license_test.go causes a race
// in initializing the CiMap and reGroupIdxs with values, this shared mutex
// prevents that race condition in the unit-test.
var (
ciMapMutex = sync.Mutex{}
reGrpIdxsMapMutex = sync.Mutex{}
)
func setCiMap() {
ciMapMutex.Lock()
defer ciMapMutex.Unlock()
if len(fsfOsiApprovedLicenseCiMap) == 0 {
for key, entry := range fsfOsiApprovedLicenseMap {
fsfOsiApprovedLicenseCiMap[strings.ToUpper(key)] = entry
}
}
}
func setGroupIdxsMap() {
reGrpIdxsMapMutex.Lock()
defer reGrpIdxsMapMutex.Unlock()
if len(reGroupIdxs) == 0 {
for idx, vl := range reGroupNames {
if vl != "" {
reGroupIdxs[vl] = idx
}
}
}
}
func getSpdxID(matches []string) string {
// try to discern an SPDX Identifier (a)
// should both "preSpdx" and "sufSpdx" have a
// value, preSpdx takes precedence.
// (e.g., 0BSD-LICENSE-GPL-2.0.txt)
// TODO: decide if that is OK or should "fail"
var id string
if matches[reGroupIdxs["preSpdx"]] != "" {
id = matches[reGroupIdxs["preSpdx"]]
} else if matches[reGroupIdxs["sufSpdx"]] != "" {
id = matches[reGroupIdxs["sufSpdx"]]
}
// Special case, the unlicense, in the map is
// called 'The Unlicense' with the Spdx id 'Unlicense'.
// For the regex's 'un' will match the [pre|suf]Spdx
// regex group (just as it would match '0BSD'), but
// 'un' will not "hit" in the map with key 'Unlicense'
if strings.EqualFold(id, "UN") {
id = "UNLICENSE"
}
return id
}
func getExt(filename string, matches []string) string {
ext := filepath.Ext(filename)
if ext != "" && strings.Contains(ext, matches[reGroupIdxs["detectedFile"]]) {
// fixes when ext incorporates part of detectedFile as ext
return ""
}
return ext
}
func getFolder(matches []string) string {
if matches[reGroupIdxs["suf"]] == "/" {
return matches[reGroupIdxs["detectedFile"]] + matches[reGroupIdxs["suf"]]
}
return ""
}
func extensionOK(ext string) bool {
if ext == "" {
return true
}
return len(reLicenseFileExts.FindStringSubmatch(ext)) != 0
}
func validateSpdxIDAndExt(matches []string, spdx, ext string) (string, string) {
if spdx == "" {
return spdx, ext
}
// fixes when [pre|suf]Spdx consumes ext
if ext != "" && strings.Contains(ext, spdx) {
spdx = ""
}
// fixes when ext incorporates part of [pre|suf]Spdx as ext
// while allowing spdx's that appear as extensions which
// start with a digit.
if ext != "" && spdx != "" && strings.Contains(spdx, ext) {
spdxDigitInExt := regexp.MustCompile(`\.[[:digit:]]`)
if !extensionOK(ext) && len(spdxDigitInExt.FindStringSubmatch(spdx)) > 0 {
ext = ""
} else {
spdx = strings.ReplaceAll(spdx, ext, "")
}
} else if ext != "" && spdx != "" && ext != spdx {
if ext != matches[reGroupIdxs["ext"]] {
spdx += matches[reGroupIdxs["ext"]]
}
}
return spdx, ext
}
func getLicensePath(matches []string, val, spdx, ext string) string {
lp := matches[reGroupIdxs["lp"]]
// at this point what matched should equal
// the input given that there must have
// been an extension or either/both some
// match to an Spdx ID, check that here.
if lp != val {
if getFolder(matches) == "" && spdx == "" {
return ""
} else if lp+ext == val {
return lp + ext
}
return ""
}
// last chance to reject file extensions which are not allowed
if !extensionOK(ext) {
return ""
}
return lp
}
func checkLicense(lfName string) (checker.LicenseFile, bool) {
grpMatches := reLicenseFile.FindStringSubmatch(lfName)
if len(grpMatches) == 0 {
return checker.LicenseFile{}, false
}
// Detected one of the mandatory file names
// quick check for done (the name passed is
// detected in its entirety)
// TODO: open/read contents to try to discern
// license as the name of the file has
// no hints.
licensePath := grpMatches[reGroupIdxs["detectedFile"]]
if lfName == licensePath {
return (checker.LicenseFile{
File: checker.File{
Path: licensePath,
Type: finding.FileTypeSource,
},
LicenseInformation: checker.License{
Name: "",
SpdxID: "",
},
}), true
}
// there is more in the file name,
// match might yield additional hints.
// a. have an (or more) Spdx Identifier, and/or
licenseSpdxID := getSpdxID(grpMatches)
// b. have an extension
licenseExt := getExt(lfName, grpMatches)
// c. or, the detected file could be a folder
// TODO: licenseFolder := getFolder(grpMatches)
// deconflict any overlap in group matches for SpdxID and any extension
licenseSpdxID, licenseExt = validateSpdxIDAndExt(grpMatches, licenseSpdxID, licenseExt)
// reset licensePath based on validated matches
licensePath = getLicensePath(grpMatches, lfName, licenseSpdxID, licenseExt)
if licensePath == "" {
return checker.LicenseFile{}, false
}
return (checker.LicenseFile{
File: checker.File{
Path: licensePath,
Type: finding.FileTypeSource, // TODO: introduce FileTypeFolder with licenseFolder
},
LicenseInformation: checker.License{
SpdxID: licenseSpdxID,
},
}), true
}
type fsfOsiLicenseType struct {
Name string
}
// SPDX license list available here: https://spdx.org/licenses (OSI approved / FSF licenses only)
// JSON formatted list: https://github.com/spdx/license-list-data/blob/main/json/licenses.json
//
// To filter and format the supported licenses, you can try the following curl command:
//
// curl https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json | \
// jq -c '.licenses[] | select((.isOsiApproved == true) or (.isFsfLibre == true)) | [.licenseId,.name]' | \
// sed 's/","/": \{ Name: "/;s/\["/"/;s/"\]/" },/' | \
// sort | \
// uniq
//
// NOTE: You may need to do additional sorting by hand to match the current alphabetization.
var fsfOsiApprovedLicenseMap = map[string]fsfOsiLicenseType{
"0BSD": {Name: "BSD Zero Clause License"},
"AAL": {Name: "Attribution Assurance License"},
"AFL-1.1": {Name: "Academic Free License v1.1"},
"AFL-1.2": {Name: "Academic Free License v1.2"},
"AFL-2.0": {Name: "Academic Free License v2.0"},
"AFL-2.1": {Name: "Academic Free License v2.1"},
"AFL-3.0": {Name: "Academic Free License v3.0"},
"AGPL-1.0": {Name: "Affero General Public License v1.0"},
"AGPL-3.0": {Name: "GNU Affero General Public License v3.0"},
"AGPL-3.0-only": {Name: "GNU Affero General Public License v3.0 only"},
"AGPL-3.0-or-later": {Name: "GNU Affero General Public License v3.0 or later"},
"APL-1.0": {Name: "Adaptive Public License 1.0"},
"APSL-1.0": {Name: "Apple Public Source License 1.0"},
"APSL-1.1": {Name: "Apple Public Source License 1.1"},
"APSL-1.2": {Name: "Apple Public Source License 1.2"},
"APSL-2.0": {Name: "Apple Public Source License 2.0"},
"Apache-1.0": {Name: "Apache License 1.0"},
"Apache-1.1": {Name: "Apache License 1.1"},
"Apache-2.0": {Name: "Apache License 2.0"},
"Artistic-1.0": {Name: "Artistic License 1.0"},
"Artistic-1.0-Perl": {Name: "Artistic License 1.0 (Perl)"},
"Artistic-1.0-cl8": {Name: "Artistic License 1.0 w/clause 8"},
"Artistic-2.0": {Name: "Artistic License 2.0"},
"BSD-1-Clause": {Name: "BSD 1-Clause License"},
"BSD-2-Clause": {Name: "BSD 2-Clause \"Simplified\" License"},
"BSD-2-Clause-FreeBSD": {Name: "BSD 2-Clause FreeBSD License"},
"BSD-2-Clause-NetBSD": {Name: "BSD 2-Clause NetBSD License"},
"BSD-2-Clause-Patent": {Name: "BSD-2-Clause Plus Patent License"},
"BSD-3-Clause": {Name: "BSD 3-Clause \"New\" or \"Revised\" License"},
"BSD-3-Clause-Clear": {Name: "BSD 3-Clause Clear License"},
"BSD-3-Clause-LBNL": {Name: "Lawrence Berkeley National Labs BSD variant license"},
"BSD-4-Clause": {Name: "BSD 4-Clause \"Original\" or \"Old\" License"},
"BSL-1.0": {Name: "Boost Software License 1.0"},
"BitTorrent-1.1": {Name: "BitTorrent Open Source License v1.1"},
"BlueOak-1.0.0": {Name: "Blue Oak Model License 1.0.0"},
"CAL-1.0": {Name: "Cryptographic Autonomy License 1.0"},
"CAL-1.0-Combined-Work-Exception": {Name: "Cryptographic Autonomy License 1.0 (Combined Work Exception)"},
"CATOSL-1.1": {Name: "Computer Associates Trusted Open Source License 1.1"},
"CC-BY-4.0": {Name: "Creative Commons Attribution 4.0 International"},
"CC-BY-SA-4.0": {Name: "Creative Commons Attribution Share Alike 4.0 International"},
"CC0-1.0": {Name: "Creative Commons Zero v1.0 Universal"},
"CDDL-1.0": {Name: "Common Development and Distribution License 1.0"},
"CECILL-2.0": {Name: "CeCILL Free Software License Agreement v2.0"},
"CECILL-2.1": {Name: "CeCILL Free Software License Agreement v2.1"},
"CECILL-B": {Name: "CeCILL-B Free Software License Agreement"},
"CECILL-C": {Name: "CeCILL-C Free Software License Agreement"},
"CERN-OHL-P-2.0": {Name: "CERN Open Hardware Licence Version 2 - Permissive"},
"CERN-OHL-S-2.0": {Name: "CERN Open Hardware Licence Version 2 - Strongly Reciprocal"},
"CERN-OHL-W-2.0": {Name: "CERN Open Hardware Licence Version 2 - Weakly Reciprocal"},
"CNRI-Python": {Name: "CNRI Python License"},
"CPAL-1.0": {Name: "Common Public Attribution License 1.0"},
"CPL-1.0": {Name: "Common Public License 1.0"},
"CUA-OPL-1.0": {Name: "CUA Office Public License v1.0"},
"ClArtistic": {Name: "Clarified Artistic License"},
"Condor-1.1": {Name: "Condor Public License v1.1"},
"ECL-1.0": {Name: "Educational Community License v1.0"},
"ECL-2.0": {Name: "Educational Community License v2.0"},
"EFL-1.0": {Name: "Eiffel Forum License v1.0"},
"EFL-2.0": {Name: "Eiffel Forum License v2.0"},
"EPL-1.0": {Name: "Eclipse Public License 1.0"},
"EPL-2.0": {Name: "Eclipse Public License 2.0"},
"EUDatagrid": {Name: "EU DataGrid Software License"},
"EUPL-1.1": {Name: "European Union Public License 1.1"},
"EUPL-1.2": {Name: "European Union Public License 1.2"},
"Entessa": {Name: "Entessa Public License v1.0"},
"FSFAP": {Name: "FSF All Permissive License"},
"FTL": {Name: "Freetype Project License"},
"Fair": {Name: "Fair License"},
"Frameworx-1.0": {Name: "Frameworx Open License 1.0"},
"GFDL-1.1": {Name: "GNU Free Documentation License v1.1"},
"GFDL-1.1-only": {Name: "GNU Free Documentation License v1.1 only"},
"GFDL-1.1-or-later": {Name: "GNU Free Documentation License v1.1 or later"},
"GFDL-1.2": {Name: "GNU Free Documentation License v1.2"},
"GFDL-1.2-only": {Name: "GNU Free Documentation License v1.2 only"},
"GFDL-1.2-or-later": {Name: "GNU Free Documentation License v1.2 or later"},
"GFDL-1.3": {Name: "GNU Free Documentation License v1.3"},
"GFDL-1.3-only": {Name: "GNU Free Documentation License v1.3 only"},
"GFDL-1.3-or-later": {Name: "GNU Free Documentation License v1.3 or later"},
"GPL-2.0": {Name: "GNU General Public License v2.0 only"},
"GPL-2.0+": {Name: "GNU General Public License v2.0 or later"},
"GPL-2.0-only": {Name: "GNU General Public License v2.0 only"},
"GPL-2.0-or-later": {Name: "GNU General Public License v2.0 or later"},
"GPL-3.0": {Name: "GNU General Public License v3.0 only"},
"GPL-3.0+": {Name: "GNU General Public License v3.0 or later"},
"GPL-3.0-only": {Name: "GNU General Public License v3.0 only"},
"GPL-3.0-or-later": {Name: "GNU General Public License v3.0 or later"},
"GPL-3.0-with-GCC-exception": {Name: "GNU General Public License v3.0 w/GCC Runtime Library exception"},
"HPND": {Name: "Historical Permission Notice and Disclaimer"},
"ICU": {Name: "ICU License"},
"IJG": {Name: "Independent JPEG Group License"},
"IPA": {Name: "IPA Font License"},
"IPL-1.0": {Name: "IBM Public License v1.0"},
"ISC": {Name: "ISC License"},
"Imlib2": {Name: "Imlib2 License"},
"Intel": {Name: "Intel Open Source License"},
"Jam": {Name: "Jam License"},
"LGPL-2.0": {Name: "GNU Library General Public License v2 only"},
"LGPL-2.0+": {Name: "GNU Library General Public License v2 or later"},
"LGPL-2.0-only": {Name: "GNU Library General Public License v2 only"},
"LGPL-2.0-or-later": {Name: "GNU Library General Public License v2 or later"},
"LGPL-2.1": {Name: "GNU Lesser General Public License v2.1 only"},
"LGPL-2.1+": {Name: "GNU Lesser General Public License v2.1 or later"},
"LGPL-2.1-only": {Name: "GNU Lesser General Public License v2.1 only"},
"LGPL-2.1-or-later": {Name: "GNU Lesser General Public License v2.1 or later"},
"LGPL-3.0": {Name: "GNU Lesser General Public License v3.0 only"},
"LGPL-3.0+": {Name: "GNU Lesser General Public License v3.0 or later"},
"LGPL-3.0-only": {Name: "GNU Lesser General Public License v3.0 only"},
"LGPL-3.0-or-later": {Name: "GNU Lesser General Public License v3.0 or later"},
"LPL-1.0": {Name: "Lucent Public License Version 1.0"},
"LPL-1.02": {Name: "Lucent Public License v1.02"},
"LPPL-1.2": {Name: "LaTeX Project Public License v1.2"},
"LPPL-1.3a": {Name: "LaTeX Project Public License v1.3a"},
"LPPL-1.3c": {Name: "LaTeX Project Public License v1.3c"},
"LiLiQ-P-1.1": {Name: "Licence Libre du Québec – Permissive version 1.1"},
"LiLiQ-R-1.1": {Name: "Licence Libre du Québec – Réciprocité version 1.1"},
"LiLiQ-Rplus-1.1": {Name: "Licence Libre du Québec – Réciprocité forte version 1.1"},
"MIT": {Name: "MIT License"},
"MIT-0": {Name: "MIT No Attribution"},
"MIT-Modern-Variant": {Name: "MIT License Modern Variant"},
"MPL-1.0": {Name: "Mozilla Public License 1.0"},
"MPL-1.1": {Name: "Mozilla Public License 1.1"},
"MPL-2.0": {Name: "Mozilla Public License 2.0"},
"MPL-2.0-no-copyleft-exception": {Name: "Mozilla Public License 2.0 (no copyleft exception)"},
"MS-PL": {Name: "Microsoft Public License"},
"MS-RL": {Name: "Microsoft Reciprocal License"},
"MirOS": {Name: "The MirOS Licence"},
"Motosoto": {Name: "Motosoto License"},
"MulanPSL-2.0": {Name: "Mulan Permissive Software License, Version 2"},
"Multics": {Name: "Multics License"},
"NASA-1.3": {Name: "NASA Open Source Agreement 1.3"},
"NCSA": {Name: "University of Illinois/NCSA Open Source License"},
"NGPL": {Name: "Nethack General Public License"},
"NOSL": {Name: "Netizen Open Source License"},
"NPL-1.0": {Name: "Netscape Public License v1.0"},
"NPL-1.1": {Name: "Netscape Public License v1.1"},
"NPOSL-3.0": {Name: "Non-Profit Open Software License 3.0"},
"NTP": {Name: "NTP License"},
"Naumen": {Name: "Naumen Public License"},
"Nokia": {Name: "Nokia Open Source License"},
"Nunit": {Name: "Nunit License"},
"OCLC-2.0": {Name: "OCLC Research Public License 2.0"},
"ODbL-1.0": {Name: "Open Data Commons Open Database License v1.0"},
"OFL-1.0": {Name: "SIL Open Font License 1.0"},
"OFL-1.1": {Name: "SIL Open Font License 1.1"},
"OFL-1.1-RFN": {Name: "SIL Open Font License 1.1 with Reserved Font Name"},
"OFL-1.1-no-RFN": {Name: "SIL Open Font License 1.1 with no Reserved Font Name"},
"OGTSL": {Name: "Open Group Test Suite License"},
"OLDAP-2.3": {Name: "Open LDAP Public License v2.3"},
"OLDAP-2.7": {Name: "Open LDAP Public License v2.7"},
"OLDAP-2.8": {Name: "Open LDAP Public License v2.8"},
"OLFL-1.3": {Name: "Open Logistics Foundation License Version 1.3"},
"OSET-PL-2.1": {Name: "OSET Public License version 2.1"},
"OSL-1.0": {Name: "Open Software License 1.0"},
"OSL-1.1": {Name: "Open Software License 1.1"},
"OSL-2.0": {Name: "Open Software License 2.0"},
"OSL-2.1": {Name: "Open Software License 2.1"},
"OSL-3.0": {Name: "Open Software License 3.0"},
"OpenSSL": {Name: "OpenSSL License"},
"PHP-3.0": {Name: "PHP License v3.0"},
"PHP-3.01": {Name: "PHP License v3.01"},
"PostgreSQL": {Name: "PostgreSQL License"},
"Python-2.0": {Name: "Python License 2.0"},
"QPL-1.0": {Name: "Q Public License 1.0"},
"RPL-1.1": {Name: "Reciprocal Public License 1.1"},
"RPL-1.5": {Name: "Reciprocal Public License 1.5"},
"RPSL-1.0": {Name: "RealNetworks Public Source License v1.0"},
"RSCPL": {Name: "Ricoh Source Code Public License"},
"Ruby": {Name: "Ruby License"},
"SGI-B-2.0": {Name: "SGI Free Software License B v2.0"},
"SISSL": {Name: "Sun Industry Standards Source License v1.1"},
"SMLNJ": {Name: "Standard ML of New Jersey License"},
"SPL-1.0": {Name: "Sun Public License v1.0"},
"SimPL-2.0": {Name: "Simple Public License 2.0"},
"Sleepycat": {Name: "Sleepycat License"},
"StandardML-NJ": {Name: "Standard ML of New Jersey License"},
"UCL-1.0": {Name: "Upstream Compatibility License v1.0"},
"UPL-1.0": {Name: "Universal Permissive License v1.0"},
"Unicode-3.0": {Name: "Unicode License v3"},
"Unicode-DFS-2016": {Name: "Unicode License Agreement - Data Files and Software (2016)"},
"Unlicense": {Name: "The Unlicense"},
"VSL-1.0": {Name: "Vovida Software License v1.0"},
"Vim": {Name: "Vim License"},
"W3C": {Name: "W3C Software Notice and License (2002-12-31)"},
"W3C-20150513": {Name: "W3C Software Notice and Document License (2015-05-13)"},
"WTFPL": {Name: "Do What The F*ck You Want To Public License"},
"Watcom-1.0": {Name: "Sybase Open Watcom Public License 1.0"},
"X11": {Name: "X11 License"},
"XFree86-1.1": {Name: "XFree86 License 1.1"},
"Xnet": {Name: "X.Net License"},
"YPL-1.1": {Name: "Yahoo! Public License v1.1"},
"ZPL-2.0": {Name: "Zope Public License 2.0"},
"ZPL-2.1": {Name: "Zope Public License 2.1"},
"Zend-2.0": {Name: "Zend License v2.0"},
"Zimbra-1.3": {Name: "Zimbra Public License v1.3"},
"Zlib": {Name: "zlib License"},
"eCos-2.0": {Name: "eCos license version 2.0"},
"gnuplot": {Name: "gnuplot License"},
"iMatix": {Name: "iMatix Standard Function Library Agreement"},
"wxWindows": {Name: "wxWindows Library License"},
"xinetd": {Name: "xinetd License"},
}
// Copyright 2022 OpenSSF 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 raw
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
)
// Maintained checks for maintenance.
func Maintained(c *checker.CheckRequest) (checker.MaintainedData, error) {
var result checker.MaintainedData
// Archived status.
archived, err := c.RepoClient.IsArchived()
if err != nil {
return result, fmt.Errorf("%w", err)
}
result.ArchivedStatus.Status = archived
// Recent commits.
commits, err := c.RepoClient.ListCommits()
if err != nil {
return result, fmt.Errorf("%w", err)
}
result.DefaultBranchCommits = commits
// Recent issues.
issues, err := c.RepoClient.ListIssues()
if err != nil {
return result, fmt.Errorf("%w", err)
}
result.Issues = issues
createdAt, err := c.RepoClient.GetCreatedAt()
if err != nil {
return result, fmt.Errorf("%w", err)
}
result.CreatedAt = createdAt
return result, nil
}
// Copyright 2021 OpenSSF 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 raw
import (
"fmt"
"strings"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/checks/raw/github"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
type permission string
const (
permissionStatuses = permission("statuses")
permissionChecks = permission("checks")
permissionSecurityEvents = permission("security-events")
permissionDeployments = permission("deployments")
permissionContents = permission("contents")
permissionPackages = permission("packages")
permissionActions = permission("actions")
)
var permissionsOfInterest = []permission{
permissionStatuses, permissionChecks,
permissionSecurityEvents, permissionDeployments,
permissionContents, permissionPackages, permissionActions,
}
type permissionCbData struct {
results checker.TokenPermissionsData
}
// TokenPermissions runs Token-Permissions check.
func TokenPermissions(c *checker.CheckRequest) (checker.TokenPermissionsData, error) {
// data is shared across all GitHub workflows.
var data permissionCbData
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: false,
}, validateGitHubActionTokenPermissions, &data)
return data.results, err
}
// Check file content.
var validateGitHubActionTokenPermissions fileparser.DoWhileTrueOnFileContent = func(path string,
content []byte,
args ...interface{},
) (bool, error) {
if !fileparser.IsWorkflowFile(path) {
return true, nil
}
// Verify the type of the data.
if len(args) != 1 {
return false, fmt.Errorf(
"validateGitHubActionTokenPermissions requires exactly 2 arguments: %w", errInvalidArgLength)
}
pdata, ok := args[0].(*permissionCbData)
if !ok {
return false, fmt.Errorf(
"validateGitHubActionTokenPermissions requires arg[0] of type *permissionCbData: %w", errInvalidArgType)
}
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
pdata.results.NumTokens += 1
workflow, errs := actionlint.Parse(content)
if len(errs) > 0 && workflow == nil {
return false, fileparser.FormatActionlintError(errs)
}
// 1. Top-level permission definitions.
//nolint:lll
// https://docs.github.com/en/actions/reference/authentication-in-a-workflow#example-1-passing-the-github_token-as-an-input,
// https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/,
// https://docs.github.com/en/actions/reference/authentication-in-a-workflow#modifying-the-permissions-for-the-github_token.
if err := validateTopLevelPermissions(workflow, path, pdata); err != nil {
return false, err
}
// 2. Run-level permission definitions,
// see https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idpermissions.
ignoredPermissions := createIgnoredPermissions(workflow, path, pdata)
if err := validatejobLevelPermissions(workflow, path, pdata, ignoredPermissions); err != nil {
return false, err
}
// TODO(laurent): 2. Identify github actions that require write and add checks.
// TODO(laurent): 3. Read a few runs and ensures they have the same permissions.
return true, nil
}
func validatePermission(permissionKey permission, permissionValue *actionlint.PermissionScope,
permLoc checker.PermissionLocation, path string, p *permissionCbData,
ignoredPermissions map[permission]bool,
) error {
if permissionValue.Value == nil {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
key := string(permissionKey)
val := permissionValue.Value.Value
lineNumber := fileparser.GetLineNumber(permissionValue.Value.Pos)
if strings.EqualFold(val, "write") {
if isPermissionOfInterest(permissionKey, ignoredPermissions) {
p.results.TokenPermissions = append(p.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: lineNumber,
Snippet: val,
},
LocationType: &permLoc,
Name: &key,
Value: &val,
Type: checker.PermissionLevelWrite,
// TODO: Job
})
} else {
// Only log for debugging, otherwise
// it may confuse users.
p.results.TokenPermissions = append(p.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: lineNumber,
Snippet: val,
},
LocationType: &permLoc,
Name: &key,
Value: &val,
// It's a write but not considered dangerous.
Type: checker.PermissionLevelUnknown,
// TODO: Job
})
}
return nil
}
p.results.TokenPermissions = append(p.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: lineNumber,
// TODO: set Snippet.
},
LocationType: &permLoc,
Name: &key,
Value: &val,
Type: typeOfPermission(val),
// TODO: Job
})
return nil
}
func typeOfPermission(val string) checker.PermissionLevel {
switch val {
case "read", "read-all":
return checker.PermissionLevelRead
case "none": //nolint:goconst
return checker.PermissionLevelNone
}
return checker.PermissionLevelUnknown
}
func validateMapPermissions(scopes map[string]*actionlint.PermissionScope, permLoc checker.PermissionLocation,
path string, pdata *permissionCbData,
ignoredPermissions map[permission]bool,
) error {
for key, v := range scopes {
if err := validatePermission(permission(key), v, permLoc, path, pdata, ignoredPermissions); err != nil {
return err
}
}
return nil
}
func validatePermissions(permissions *actionlint.Permissions, permLoc checker.PermissionLocation,
path string, pdata *permissionCbData,
ignoredPermissions map[permission]bool,
) error {
allIsSet := permissions != nil && permissions.All != nil && permissions.All.Value != ""
scopeIsSet := permissions != nil && len(permissions.Scopes) > 0
none := "none"
if permissions == nil || (!allIsSet && !scopeIsSet) {
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
LocationType: &permLoc,
Type: checker.PermissionLevelNone,
Value: &none,
// TODO: Job, etc.
})
}
if allIsSet {
val := permissions.All.Value
lineNumber := fileparser.GetLineNumber(permissions.All.Pos)
if !strings.EqualFold(val, "read-all") && val != "" {
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: lineNumber,
Snippet: val,
},
LocationType: &permLoc,
Value: &val,
Type: checker.PermissionLevelWrite,
// TODO: Job
})
return nil
}
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: lineNumber,
Snippet: val,
},
LocationType: &permLoc,
Value: &val,
Type: typeOfPermission(val),
// TODO: Job
})
} else /* scopeIsSet == true */ if err := validateMapPermissions(permissions.Scopes,
permLoc, path, pdata, ignoredPermissions); err != nil {
return err
}
return nil
}
func validateTopLevelPermissions(workflow *actionlint.Workflow, path string,
pdata *permissionCbData,
) error {
// Check if permissions are set explicitly.
if workflow.Permissions == nil {
permLoc := checker.PermissionLocationTop
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
LocationType: &permLoc,
Type: checker.PermissionLevelUndeclared,
// TODO: Job
})
return nil
}
return validatePermissions(workflow.Permissions, checker.PermissionLocationTop, path,
pdata, map[permission]bool{})
}
func validatejobLevelPermissions(workflow *actionlint.Workflow, path string,
pdata *permissionCbData,
ignoredPermissions map[permission]bool,
) error {
for _, job := range workflow.Jobs {
// Run-level permissions may be left undefined.
// For most workflows, no write permissions are needed,
// so only top-level read-only permissions need to be declared.
if job.Permissions == nil {
permLoc := checker.PermissionLocationJob
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: path,
Type: finding.FileTypeSource,
Offset: fileparser.GetLineNumber(job.Pos),
},
LocationType: &permLoc,
Type: checker.PermissionLevelUndeclared,
Msg: github.StringPointer(fmt.Sprintf("no %s permission defined", permLoc)),
// TODO: Job
})
continue
}
err := validatePermissions(job.Permissions, checker.PermissionLocationJob,
path, pdata, ignoredPermissions)
if err != nil {
return err
}
}
return nil
}
func isPermissionOfInterest(name permission, ignoredPermissions map[permission]bool) bool {
for _, p := range permissionsOfInterest {
_, present := ignoredPermissions[p]
if strings.EqualFold(string(name), string(p)) && !present {
return true
}
}
return false
}
func createIgnoredPermissions(workflow *actionlint.Workflow, fp string,
pdata *permissionCbData,
) map[permission]bool {
ignoredPermissions := make(map[permission]bool)
if requiresPackagesPermissions(workflow, fp, pdata) {
ignoredPermissions[permissionPackages] = true
}
if requiresContentsPermissions(workflow, fp, pdata) {
ignoredPermissions[permissionContents] = true
}
if isSARIFUploadWorkflow(workflow, fp, pdata) {
ignoredPermissions[permissionSecurityEvents] = true
}
return ignoredPermissions
}
// Scanning tool run externally and SARIF file uploaded.
func isSARIFUploadWorkflow(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
// TODO: some third party tools may upload directly through their actions.
// Very unlikely.
// See https://github.com/marketplace for tools.
return isAllowedWorkflow(workflow, fp, pdata)
}
func isAllowedWorkflow(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
//nolint:lll
allowlist := map[string]bool{
// CodeQl analysis workflow automatically sends sarif file to GitHub.
// https://docs.github.com/en/code-security/secure-coding/integrating-with-code-scanning/uploading-a-sarif-file-to-github#about-sarif-file-uploads-for-code-scanning.
// `The CodeQL action uploads the SARIF file automatically when it completes analysis`.
"github/codeql-action/analyze": true,
// Third-party scanning tools use the SARIF-upload action from code-ql.
// https://docs.github.com/en/code-security/secure-coding/integrating-with-code-scanning/uploading-a-sarif-file-to-github#uploading-a-code-scanning-analysis-with-github-actions
// We only support CodeQl today.
"github/codeql-action/upload-sarif": true,
// allow our own action, which writes sarif files
// https://github.com/ossf/scorecard-action
"ossf/scorecard-action": true,
// Code scanning with HLint uploads a SARIF file to GitHub.
// https://github.com/haskell-actions/hlint-scan
"haskell-actions/hlint-scan": true,
}
tokenPermissions := checker.TokenPermission{
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
// TODO: set Snippet.
},
Type: checker.PermissionLevelUnknown,
// TODO: Job
}
for _, job := range workflow.Jobs {
for _, step := range job.Steps {
uses := fileparser.GetUses(step)
if uses == nil {
continue
}
// remove any version pinning for the comparison
uses.Value = strings.Split(uses.Value, "@")[0]
if allowlist[uses.Value] {
tokenPermissions.File.Offset = fileparser.GetLineNumber(uses.Pos)
tokenPermissions.Msg = github.StringPointer("allowed SARIF workflow detected")
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions, tokenPermissions)
return true
}
}
}
tokenPermissions.Msg = github.StringPointer("not a SARIF workflow, or not an allowed one")
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions, tokenPermissions)
return false
}
// A packaging workflow using GitHub's supported packages:
// https://docs.github.com/en/packages.
func requiresPackagesPermissions(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
// TODO: add support for GitHub registries.
// Example: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry.
match, ok := fileparser.IsPackagingWorkflow(workflow, fp)
// Print debug messages.
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
Msg: &match.Msg,
Type: checker.PermissionLevelUnknown,
})
return ok
}
// requiresContentsPermissions returns true if the workflow requires the `contents: write` permission.
func requiresContentsPermissions(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
return isReleasingWorkflow(workflow, fp, pdata) || isGitHubPagesDeploymentWorkflow(workflow, fp, pdata)
}
// isGitHubPagesDeploymentWorkflow returns true if the workflow involves pushing static pages to GitHub pages.
func isGitHubPagesDeploymentWorkflow(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
jobMatchers := []fileparser.JobMatcher{
{
Steps: []*fileparser.JobMatcherStep{
{
Uses: "peaceiris/actions-gh-pages",
},
},
LogText: "candidate GitHub page deployment workflow using peaceiris/actions-gh-pages",
},
}
return isWorkflowOf(workflow, fp, jobMatchers,
"not a GitHub Pages deployment workflow", pdata)
}
// isReleasingWorkflow returns true if the workflow involves creating a release on GitHub.
func isReleasingWorkflow(workflow *actionlint.Workflow, fp string, pdata *permissionCbData) bool {
jobMatchers := []fileparser.JobMatcher{
{
// Python packages.
// This is a custom Python packaging/releasing workflow based on semantic versioning.
Steps: []*fileparser.JobMatcherStep{
{
Uses: "relekang/python-semantic-release",
},
},
LogText: "candidate python publishing workflow using python-semantic-release",
},
{
// Commonly JavaScript packages, but supports multiple ecosystems
Steps: []*fileparser.JobMatcherStep{
{
Run: "(npx|pnpm|yarn).*semantic-release",
},
},
LogText: "candidate publishing workflow using semantic-release",
},
{
// Go binaries.
Steps: []*fileparser.JobMatcherStep{
{
Uses: "actions/setup-go",
},
{
Uses: "goreleaser/goreleaser-action",
},
},
LogText: "candidate golang publishing workflow",
},
{
// SLSA Go builder. https://github.com/slsa-framework/slsa-github-generator
Steps: []*fileparser.JobMatcherStep{
{
Uses: "slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml",
},
},
LogText: "candidate SLSA publishing workflow using slsa-github-generator",
},
{
// SLSA generic generator. https://github.com/slsa-framework/slsa-github-generator
Steps: []*fileparser.JobMatcherStep{
{
Uses: "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml",
},
},
LogText: "candidate SLSA publishing workflow using slsa-github-generator",
},
{
// Running mvn release:prepare requires committing changes.
// https://maven.apache.org/maven-release/maven-release-plugin/examples/prepare-release.html
Steps: []*fileparser.JobMatcherStep{
{
Run: ".*mvn.*release:prepare.*",
},
},
LogText: "candidate mvn release workflow",
},
}
return isWorkflowOf(workflow, fp, jobMatchers, "not a releasing workflow", pdata)
}
func isWorkflowOf(workflow *actionlint.Workflow, fp string,
jobMatchers []fileparser.JobMatcher, msg string,
pdata *permissionCbData,
) bool {
match, ok := fileparser.AnyJobsMatch(workflow, jobMatchers, fp, msg)
// Print debug messages.
pdata.results.TokenPermissions = append(pdata.results.TokenPermissions,
checker.TokenPermission{
File: &checker.File{
Path: fp,
Type: finding.FileTypeSource,
Offset: checker.OffsetDefault,
},
Msg: &match.Msg,
Type: checker.PermissionLevelUnknown,
})
return ok
}
// Copyright 2022 OpenSSF 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 raw
import (
"errors"
"fmt"
"path/filepath"
"reflect"
"regexp"
"strings"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/dotnet/csproj"
"github.com/ossf/scorecard/v5/internal/dotnet/properties"
"github.com/ossf/scorecard/v5/remediation"
)
type dotnetCsprojLockedData struct {
Path string
LockedModeSet bool
}
type nugetPostProcessData struct {
CsprojConfigs []dotnetCsprojLockedData
CpmConfig properties.CentralPackageManagementConfig
}
// PinningDependencies checks for (un)pinned dependencies.
func PinningDependencies(c *checker.CheckRequest) (checker.PinningDependenciesData, error) {
var results checker.PinningDependenciesData
// GitHub actions.
if err := collectGitHubActionsWorkflowPinning(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
// // Docker files.
if err := collectDockerfilePinning(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
// Docker downloads.
if err := collectDockerfileInsecureDownloads(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
// Script downloads.
if err := collectShellScriptInsecureDownloads(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
// Action script downloads.
if err := collectGitHubWorkflowScriptInsecureDownloads(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
// Nuget Post Processing
if err := postProcessNugetDependencies(c, &results); err != nil {
return checker.PinningDependenciesData{}, err
}
return results, nil
}
func postProcessNugetDependencies(c *checker.CheckRequest,
pinningDependenciesData *checker.PinningDependenciesData,
) error {
unpinnedDependencies := getUnpinnedNugetDependencies(pinningDependenciesData)
if len(unpinnedDependencies) == 0 {
return nil
}
var nugetPostProcessData nugetPostProcessData
if err := retrieveNugetCentralPackageManagement(c, &nugetPostProcessData); err != nil {
return err
}
if err := retrieveCsprojConfig(c, &nugetPostProcessData); err != nil {
return err
}
if nugetPostProcessData.CpmConfig.IsCPMEnabled {
collectPostProcessNugetCPMDependencies(unpinnedDependencies, &nugetPostProcessData)
} else {
collectPostProcessNugetCsprojDependencies(unpinnedDependencies, &nugetPostProcessData)
}
return nil
}
func collectPostProcessNugetCPMDependencies(unpinnedNugetDependencies []*checker.Dependency,
postProcessingData *nugetPostProcessData,
) {
packageVersions := postProcessingData.CpmConfig.PackageVersions
numUnfixedVersions, unfixedVersions := countUnfixedVersions(packageVersions)
// if all dependencies are fixed to specific versions, pin all dependencies
if numUnfixedVersions == 0 {
pinAllNugetDependencies(unpinnedNugetDependencies)
return
}
// if some or all dependencies are not fixed to specific versions, update the remediation
for i := range unpinnedNugetDependencies {
(unpinnedNugetDependencies)[i].Remediation.Text = (unpinnedNugetDependencies)[i].Remediation.Text +
": some of dependency versions are not fixes to specific versions: " + unfixedVersions
}
}
func retrieveNugetCentralPackageManagement(c *checker.CheckRequest, nugetPostProcessData *nugetPostProcessData) error {
if err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "Directory.*.props",
CaseSensitive: false,
}, processDirectoryPropsFile, nugetPostProcessData, c.Dlogger); err != nil {
return err
}
return nil
}
func processDirectoryPropsFile(path string, content []byte, args ...interface{}) (bool, error) {
pdata, ok := args[0].(*nugetPostProcessData)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type nugetPostProcessData, got %v", reflect.TypeOf(args[0])))
}
cpmConfig, err := properties.GetCentralPackageManagementConfig(path, content)
if err != nil {
dl, ok := args[1].(checker.DetailLogger)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type checker.DetailLogger, got %v", reflect.TypeOf(args[1])))
}
dl.Warn(&checker.LogMessage{
Text: fmt.Sprintf("malformed properties file: %v", err),
})
return true, nil
}
pdata.CpmConfig = cpmConfig
return false, nil
}
func getUnpinnedNugetDependencies(pinningDependenciesData *checker.PinningDependenciesData) []*checker.Dependency {
var unpinnedNugetDependencies []*checker.Dependency
nugetDependencies := getDependenciesByType(pinningDependenciesData, checker.DependencyUseTypeNugetCommand)
for i := range nugetDependencies {
if !*nugetDependencies[i].Pinned {
unpinnedNugetDependencies = append(unpinnedNugetDependencies, nugetDependencies[i])
}
}
return unpinnedNugetDependencies
}
func getDependenciesByType(p *checker.PinningDependenciesData,
useType checker.DependencyUseType,
) []*checker.Dependency {
var deps []*checker.Dependency
for i := range p.Dependencies {
if p.Dependencies[i].Type == useType {
deps = append(deps, &p.Dependencies[i])
}
}
return deps
}
func collectPostProcessNugetCsprojDependencies(unpinnedNugetDependencies []*checker.Dependency,
postProcessingData *nugetPostProcessData,
) {
unlockedCsprojDeps, unlockedPath := countUnlocked(postProcessingData.CsprojConfigs)
switch unlockedCsprojDeps {
case len(postProcessingData.CsprojConfigs):
// none of the csproject files set RestoreLockedMode. Keep the same status of the nuget dependencies
return
case 0:
// all csproj files set RestoreLockedMode, update the dependency pinning status of all nuget dependencies to pinned
pinAllNugetDependencies(unpinnedNugetDependencies)
default:
// only some csproj files are locked, keep the same status of the nuget dependencies but create a remediation
for i := range unpinnedNugetDependencies {
(unpinnedNugetDependencies)[i].Remediation.Text = (unpinnedNugetDependencies)[i].Remediation.Text +
": some of your csproj files set the RestoreLockedMode property to true, " +
"while other do not set it: " + unlockedPath
}
}
}
func pinAllNugetDependencies(dependencies []*checker.Dependency) {
for i := range dependencies {
if dependencies[i].Type == checker.DependencyUseTypeNugetCommand {
dependencies[i].Pinned = asBoolPointer(true)
dependencies[i].Remediation = nil
}
}
}
func retrieveCsprojConfig(c *checker.CheckRequest, nugetPostProcessData *nugetPostProcessData) error {
if err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "*.csproj",
CaseSensitive: false,
}, analyseCsprojLockedMode, &nugetPostProcessData.CsprojConfigs, c.Dlogger); err != nil {
return err
}
return nil
}
func analyseCsprojLockedMode(path string, content []byte, args ...interface{}) (bool, error) {
pdata, ok := args[0].(*[]dotnetCsprojLockedData)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type *[]dotnetCsprojLockedData, got %v", reflect.TypeOf(args[0])))
}
pinned, err := csproj.IsRestoreLockedModeEnabled(content)
if err != nil {
dl, ok := args[1].(checker.DetailLogger)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type checker.DetailLogger, got %v", reflect.TypeOf(args[1])))
}
dl.Warn(&checker.LogMessage{
Text: fmt.Sprintf("malformed csproj file: %v", err),
})
return true, nil
}
csprojData := dotnetCsprojLockedData{
Path: path,
LockedModeSet: pinned,
}
*pdata = append(*pdata, csprojData)
return true, nil
}
func countUnlocked(csprojFiles []dotnetCsprojLockedData) (int, string) {
var unlockedPaths []string
for i := range csprojFiles {
if !csprojFiles[i].LockedModeSet {
unlockedPaths = append(unlockedPaths, csprojFiles[i].Path)
}
}
return len(unlockedPaths), strings.Join(unlockedPaths, ", ")
}
func countUnfixedVersions(packages []properties.NugetPackage) (int, string) {
var unfixedVersions []string
for i := range packages {
if !packages[i].IsFixed {
unfixedVersions = append(unfixedVersions, packages[i].Version)
}
}
return len(unfixedVersions), strings.Join(unfixedVersions, ", ")
}
func dataAsPinnedDependenciesPointer(data interface{}) *checker.PinningDependenciesData {
pdata, ok := data.(*checker.PinningDependenciesData)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type PinningDependenciesData, got %v", reflect.TypeOf(data)))
}
return pdata
}
func collectShellScriptInsecureDownloads(c *checker.CheckRequest, r *checker.PinningDependenciesData) error {
return fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "*",
CaseSensitive: false,
}, validateShellScriptIsFreeOfInsecureDownloads, r)
}
var validateShellScriptIsFreeOfInsecureDownloads fileparser.DoWhileTrueOnFileContent = func(
pathfn string,
content []byte,
args ...interface{},
) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf(
"validateShellScriptIsFreeOfInsecureDownloads requires exactly 1 arguments: got %v: %w",
len(args), errInvalidArgLength)
}
pdata := dataAsPinnedDependenciesPointer(args[0])
// Validate the file type.
if !isSupportedShellScriptFile(pathfn, content) {
return true, nil
}
if err := validateShellFile(pathfn, 0, 0, content, map[string]bool{}, pdata); err != nil {
return false, nil
}
return true, nil
}
func collectDockerfileInsecureDownloads(c *checker.CheckRequest, r *checker.PinningDependenciesData) error {
return fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "*Dockerfile*",
CaseSensitive: false,
}, validateDockerfileInsecureDownloads, r)
}
func fileIsInVendorDir(pathfn string) bool {
cleanedPath := filepath.Clean(pathfn)
splitCleanedPath := strings.Split(cleanedPath, "/")
for _, d := range splitCleanedPath {
if strings.EqualFold(d, "vendor") {
return true
}
if strings.EqualFold(d, "third_party") {
return true
}
}
return false
}
var validateDockerfileInsecureDownloads fileparser.DoWhileTrueOnFileContent = func(
pathfn string,
content []byte,
args ...interface{},
) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf(
"validateDockerfileInsecureDownloads requires exactly 1 arguments: got %v: %w",
len(args), errInvalidArgLength)
}
if fileIsInVendorDir(pathfn) {
return true, nil
}
pdata := dataAsPinnedDependenciesPointer(args[0])
// Return early if this is not a docker file.
if !isDockerfile(pathfn, content) {
return true, nil
}
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
contentReader := strings.NewReader(string(content))
res, err := parser.Parse(contentReader)
if err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInternalInvalidDockerFile, err))
}
// Walk the Dockerfile's AST.
taintedFiles := make(map[string]bool)
for i := range res.AST.Children {
child := res.AST.Children[i]
cmdType := child.Value
// Only look for the 'RUN' command.
if cmdType != "RUN" {
continue
}
if len(child.Heredocs) > 0 {
startOffset := 1
for _, heredoc := range child.Heredocs {
cmd := heredoc.Content
lineCount := startOffset + strings.Count(cmd, "\n")
if err := validateShellFile(pathfn, uint(child.StartLine+startOffset)-1, uint(child.StartLine+lineCount)-2,
[]byte(cmd), taintedFiles, pdata); err != nil {
return false, err
}
startOffset += lineCount
}
} else {
var valueList []string
for n := child.Next; n != nil; n = n.Next {
valueList = append(valueList, n.Value)
}
if len(valueList) == 0 {
return false, sce.WithMessage(sce.ErrScorecardInternal, errInternalInvalidDockerFile.Error())
}
// Build a file content.
cmd := strings.Join(valueList, " ")
if err := validateShellFile(pathfn, uint(child.StartLine)-1, uint(child.EndLine)-1,
[]byte(cmd), taintedFiles, pdata); err != nil {
return false, err
}
}
}
return true, nil
}
func isDockerfile(pathfn string, content []byte) bool {
if strings.HasSuffix(pathfn, ".go") ||
strings.HasSuffix(pathfn, ".c") ||
strings.HasSuffix(pathfn, ".cpp") ||
strings.HasSuffix(pathfn, ".rs") ||
strings.HasSuffix(pathfn, ".js") ||
strings.HasSuffix(pathfn, ".py") ||
strings.HasSuffix(pathfn, ".pyc") ||
strings.HasSuffix(pathfn, ".java") ||
isShellScriptFile(pathfn, content) {
return false
}
return true
}
func collectDockerfilePinning(c *checker.CheckRequest, r *checker.PinningDependenciesData) error {
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "*Dockerfile*",
CaseSensitive: false,
}, validateDockerfilesPinning, r)
if err != nil {
return err
}
applyDockerfilePinningRemediations(r.Dependencies)
return nil
}
func applyDockerfilePinningRemediations(d []checker.Dependency) {
for i := range d {
rr := &d[i]
if rr.Type == checker.DependencyUseTypeDockerfileContainerImage && !*rr.Pinned {
remediate := remediation.CreateDockerfilePinningRemediation(rr, remediation.CraneDigester{})
rr.Remediation = remediate
}
}
}
var validateDockerfilesPinning fileparser.DoWhileTrueOnFileContent = func(
pathfn string,
content []byte,
args ...interface{},
) (bool, error) {
// Users may use various names, e.g.,
// Dockerfile.aarch64, Dockerfile.template, Dockerfile_template, dockerfile, Dockerfile-name.template
if len(args) != 1 {
return false, fmt.Errorf(
"validateDockerfilesPinning requires exactly 2 arguments: got %v: %w", len(args), errInvalidArgLength)
}
if fileIsInVendorDir(pathfn) {
return true, nil
}
pdata := dataAsPinnedDependenciesPointer(args[0])
// Return early if this is not a dockerfile.
if !isDockerfile(pathfn, content) {
return true, nil
}
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
if fileparser.IsTemplateFile(pathfn) {
return true, nil
}
// We have what looks like a docker file.
// Let's interpret the content as utf8-encoded strings.
contentReader := strings.NewReader(string(content))
// The dependency must be pinned by sha256 hash, e.g.,
// FROM something@sha256:${ARG},
// FROM something:@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2
regex := regexp.MustCompile(`.*@sha256:([a-f\d]{64}|\${.*})`)
pinnedAsNames := make(map[string]bool)
res, err := parser.Parse(contentReader)
if err != nil {
return false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInternalInvalidDockerFile, err))
}
for _, child := range res.AST.Children {
cmdType := child.Value
if cmdType != "FROM" {
continue
}
var valueList []string
for n := child.Next; n != nil; n = n.Next {
valueList = append(valueList, n.Value)
}
switch {
// scratch is no-op.
case len(valueList) > 0 && strings.EqualFold(valueList[0], "scratch"):
if len(valueList) == 3 && strings.EqualFold(valueList[1], "as") {
pinnedAsNames[valueList[2]] = true
}
continue
// FROM name AS newname.
case len(valueList) == 3 && strings.EqualFold(valueList[1], "as"):
name := valueList[0]
asName := valueList[2]
// Check if the name is pinned.
// (1): name = <>@sha245:hash
// (2): name = XXX where XXX was pinned
pinned := pinnedAsNames[name]
// Record the asName.
if pinned || regex.MatchString(name) {
pinnedAsNames[asName] = true
} else {
pinnedAsNames[asName] = false
}
pdata.Dependencies = append(pdata.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: uint(child.StartLine),
EndOffset: uint(child.EndLine),
Snippet: child.Original,
},
Name: asPointer(name),
PinnedAt: asPointer(asName),
Pinned: asBoolPointer(pinnedAsNames[asName]),
Type: checker.DependencyUseTypeDockerfileContainerImage,
},
)
// FROM name.
case len(valueList) == 1:
name := valueList[0]
pinned := pinnedAsNames[name]
dep := checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: uint(child.StartLine),
EndOffset: uint(child.EndLine),
Snippet: child.Original,
},
Pinned: asBoolPointer(pinned || regex.MatchString(name)),
Type: checker.DependencyUseTypeDockerfileContainerImage,
}
parts := strings.SplitN(name, ":", 2)
if len(parts) > 0 {
dep.Name = asPointer(parts[0])
if len(parts) > 1 {
dep.PinnedAt = asPointer(parts[1])
}
}
pdata.Dependencies = append(pdata.Dependencies, dep)
default:
// That should not happen.
return false, sce.WithMessage(sce.ErrScorecardInternal, errInternalInvalidDockerFile.Error())
}
}
//nolint:lll
// The file need not have a FROM statement,
// https://github.com/tensorflow/tensorflow/blob/master/tensorflow/tools/dockerfiles/partials/jupyter.partial.Dockerfile.
return true, nil
}
func collectGitHubWorkflowScriptInsecureDownloads(c *checker.CheckRequest, r *checker.PinningDependenciesData) error {
return fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: false,
}, validateGitHubWorkflowIsFreeOfInsecureDownloads, r)
}
// validateGitHubWorkflowIsFreeOfInsecureDownloads checks if the workflow file downloads dependencies that are unpinned.
// Returns true if the check should continue executing after this file.
var validateGitHubWorkflowIsFreeOfInsecureDownloads fileparser.DoWhileTrueOnFileContent = func(
pathfn string,
content []byte,
args ...interface{},
) (bool, error) {
if !fileparser.IsWorkflowFile(pathfn) {
return true, nil
}
if len(args) != 1 {
return false, fmt.Errorf(
"validateGitHubWorkflowIsFreeOfInsecureDownloads requires exactly 1 arguments: got %v: %w",
len(args), errInvalidArgLength)
}
pdata := dataAsPinnedDependenciesPointer(args[0])
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
workflow, errs := actionlint.Parse(content)
if len(errs) > 0 && workflow == nil {
// actionlint is a linter, so it will return errors when the yaml file does not meet its linting standards.
// Often we don't care about these errors.
return false, fileparser.FormatActionlintError(errs)
}
githubVarRegex := regexp.MustCompile(`{{[^{}]*}}`)
for jobName, job := range workflow.Jobs {
if len(fileparser.GetJobName(job)) > 0 {
jobName = fileparser.GetJobName(job)
}
taintedFiles := make(map[string]bool)
for _, step := range job.Steps {
if !fileparser.IsStepExecKind(step, actionlint.ExecKindRun) {
continue
}
execRun, ok := step.Exec.(*actionlint.ExecRun)
if !ok {
stepName := fileparser.GetStepName(step)
return false, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("unable to parse step '%v' for job '%v'", jobName, stepName))
}
if execRun == nil || execRun.Run == nil {
// Cannot check further, continue.
continue
}
run := execRun.Run.Value
// https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsrun.
shell, err := fileparser.GetShellForStep(step, job)
if err != nil {
var elementError *checker.ElementError
if errors.As(err, &elementError) {
// Add the workflow name and step ID to the element
lineStart := uint(step.Pos.Line)
elementError.Location = finding.Location{
Path: pathfn,
Snippet: elementError.Location.Snippet,
LineStart: &lineStart,
Type: finding.FileTypeSource,
}
pdata.ProcessingErrors = append(pdata.ProcessingErrors, *elementError)
// continue instead of break because other `run` steps may declare
// a valid shell we can scan
continue
}
return false, err
}
// Skip unsupported shells. We don't support Windows shells or some Unix shells.
if !isSupportedShell(shell) {
continue
}
// We replace the `${{ github.variable }}` to avoid shell parsing failures.
script := githubVarRegex.ReplaceAll([]byte(run), []byte("GITHUB_REDACTED_VAR"))
if err := validateShellFile(pathfn, uint(execRun.Run.Pos.Line), uint(execRun.Run.Pos.Line),
script, taintedFiles, pdata); err != nil {
pdata.Dependencies = append(pdata.Dependencies, checker.Dependency{
Msg: asPointer(err.Error()),
})
}
}
}
return true, nil
}
// Check pinning of github actions in workflows.
func collectGitHubActionsWorkflowPinning(c *checker.CheckRequest, r *checker.PinningDependenciesData) error {
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: true,
}, validateGitHubActionWorkflow, r)
if err != nil {
return err
}
//nolint:errcheck
remediationMetadata, _ := remediation.New(c)
applyWorkflowPinningRemediations(remediationMetadata, r.Dependencies)
return nil
}
func applyWorkflowPinningRemediations(rm *remediation.RemediationMetadata, d []checker.Dependency) {
for i := range d {
rr := &d[i]
if rr.Type == checker.DependencyUseTypeGHAction && !*rr.Pinned {
remediate := rm.CreateWorkflowPinningRemediation(rr.Location.Path)
rr.Remediation = remediate
}
}
}
// validateGitHubActionWorkflow checks if the workflow file contains unpinned actions. Returns true if the check
// should continue executing after this file.
var validateGitHubActionWorkflow fileparser.DoWhileTrueOnFileContent = func(
pathfn string,
content []byte,
args ...interface{},
) (bool, error) {
if !fileparser.IsWorkflowFile(pathfn) {
return true, nil
}
if len(args) != 1 {
return false, fmt.Errorf(
"validateGitHubActionWorkflow requires exactly 1 arguments: got %v: %w", len(args), errInvalidArgLength)
}
pdata := dataAsPinnedDependenciesPointer(args[0])
if !fileparser.CheckFileContainsCommands(content, "#") {
return true, nil
}
workflow, errs := actionlint.Parse(content)
if len(errs) > 0 && workflow == nil {
// actionlint is a linter, so it will return errors when the yaml file does not meet its linting standards.
// Often we don't care about these errors.
return false, fileparser.FormatActionlintError(errs)
}
for jobName, job := range workflow.Jobs {
if len(fileparser.GetJobName(job)) > 0 {
jobName = fileparser.GetJobName(job)
}
if job.WorkflowCall != nil && job.WorkflowCall.Uses != nil {
//nolint:lll
// Check whether this is an action defined in the same repo,
// https://docs.github.com/en/actions/learn-github-actions/finding-and-customizing-actions#referencing-an-action-in-the-same-repository-where-a-workflow-file-uses-the-action.
if !strings.HasPrefix(job.WorkflowCall.Uses.Value, "./") {
dep := newGHActionDependency(job.WorkflowCall.Uses.Value, pathfn, job.WorkflowCall.Uses.Pos.Line)
pdata.Dependencies = append(pdata.Dependencies, dep)
}
}
for _, step := range job.Steps {
if !fileparser.IsStepExecKind(step, actionlint.ExecKindAction) {
continue
}
execAction, ok := step.Exec.(*actionlint.ExecAction)
if !ok {
stepName := fileparser.GetStepName(step)
return false, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("unable to parse step '%v' for job '%v'", jobName, stepName))
}
if execAction == nil || execAction.Uses == nil {
// Cannot check further, continue.
continue
}
//nolint:lll
// Check whether this is an action defined in the same repo,
// https://docs.github.com/en/actions/learn-github-actions/finding-and-customizing-actions#referencing-an-action-in-the-same-repository-where-a-workflow-file-uses-the-action.
if strings.HasPrefix(execAction.Uses.Value, "./") {
continue
}
dep := newGHActionDependency(execAction.Uses.Value, pathfn, execAction.Uses.Pos.Line)
pdata.Dependencies = append(pdata.Dependencies, dep)
}
}
return true, nil
}
func newGHActionDependency(uses, pathfn string, line int) checker.Dependency {
dep := checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: uint(line),
EndOffset: uint(line), // `Uses` always span a single line.
Snippet: uses,
},
Pinned: asBoolPointer(isActionDependencyPinned(uses)),
Type: checker.DependencyUseTypeGHAction,
}
parts := strings.SplitN(uses, "@", 2)
if len(parts) > 0 {
dep.Name = asPointer(parts[0])
if len(parts) > 1 {
dep.PinnedAt = asPointer(parts[1])
}
}
return dep
}
func isActionDependencyPinned(actionUses string) bool {
localActionRegex := regexp.MustCompile(`^\..+[^/]`)
if localActionRegex.MatchString(actionUses) {
return true
}
publicActionRegex := regexp.MustCompile(`.*@[a-fA-F\d]{40,}`)
if publicActionRegex.MatchString(actionUses) {
return true
}
dockerhubActionRegex := regexp.MustCompile(`docker://.*@sha256:[a-fA-F\d]{64}`)
return dockerhubActionRegex.MatchString(actionUses)
}
// Copyright 2023 OpenSSF 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 raw
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"path"
"regexp"
"strings"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
const CheckSAST = "SAST"
var errInvalid = errors.New("invalid")
var sastTools = map[string]bool{
"github-advanced-security": true,
"github-code-scanning": true,
"lgtm-com": true,
"sonarcloud": true,
"sonarqubecloud": true,
}
var allowedConclusions = map[string]bool{"success": true, "neutral": true}
// SAST checks for presence of static analysis tools.
func SAST(c *checker.CheckRequest) (checker.SASTData, error) {
var data checker.SASTData
commits, err := sastToolInCheckRuns(c)
if err != nil {
return data, err
}
data.Commits = commits
codeQLWorkflows, err := getSastUsesWorkflows(c, "^github/codeql-action/analyze$", checker.CodeQLWorkflow)
if err != nil {
return data, err
}
data.Workflows = append(data.Workflows, codeQLWorkflows...)
sonarWorkflows, err := getSonarWorkflows(c)
if err != nil {
return data, err
}
data.Workflows = append(data.Workflows, sonarWorkflows...)
snykWorkflows, err := getSastUsesWorkflows(c, "^snyk/actions/.*", checker.SnykWorkflow)
if err != nil {
return data, err
}
data.Workflows = append(data.Workflows, snykWorkflows...)
pysaWorkflows, err := getSastUsesWorkflows(c, "^facebook/pysa-action$", checker.PysaWorkflow)
if err != nil {
return data, err
}
data.Workflows = append(data.Workflows, pysaWorkflows...)
qodanaWorkflows, err := getSastUsesWorkflows(c, "^JetBrains/qodana-action$", checker.QodanaWorkflow)
if err != nil {
return data, err
}
data.Workflows = append(data.Workflows, qodanaWorkflows...)
return data, nil
}
func sastToolInCheckRuns(c *checker.CheckRequest) ([]checker.SASTCommit, error) {
var sastCommits []checker.SASTCommit
commits, err := c.RepoClient.ListCommits()
if err != nil {
// ignoring check for local dir
if errors.Is(err, clients.ErrUnsupportedFeature) {
return sastCommits, nil
}
return sastCommits,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("RepoClient.ListCommits: %v", err))
}
for i := range commits {
pr := commits[i].AssociatedMergeRequest
// TODO(#575): We ignore associated PRs if Scorecard is being run on a fork
// but the PR was created in the original repo.
if pr.MergedAt.IsZero() {
continue
}
checked := false
crs, err := c.RepoClient.ListCheckRunsForRef(pr.HeadSHA)
if err != nil {
return sastCommits,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Client.Checks.ListCheckRunsForRef: %v", err))
}
// Note: crs may be `nil`: in this case
// the loop below will be skipped.
for _, cr := range crs {
if cr.Status != "completed" {
continue
}
if !allowedConclusions[cr.Conclusion] {
continue
}
if sastTools[cr.App.Slug] {
if c.Dlogger != nil {
c.Dlogger.Debug(&checker.LogMessage{
Path: cr.URL,
Type: finding.FileTypeURL,
Text: fmt.Sprintf("tool detected: %v", cr.App.Slug),
})
}
checked = true
break
}
}
sastCommit := checker.SASTCommit{
CommittedDate: commits[i].CommittedDate,
Message: commits[i].Message,
SHA: commits[i].SHA,
AssociatedMergeRequest: commits[i].AssociatedMergeRequest,
Committer: commits[i].Committer,
Compliant: checked,
}
sastCommits = append(sastCommits, sastCommit)
}
return sastCommits, nil
}
// getSastUsesWorkflows matches if the "uses" field of a GitHub action matches
// a given regex by way of usesRegex. Each workflow that matches the usesRegex
// is appended to the slice that is returned.
func getSastUsesWorkflows(
c *checker.CheckRequest,
usesRegex string,
checkerType checker.SASTWorkflowType,
) ([]checker.SASTWorkflow, error) {
var workflowPaths []string
var sastWorkflows []checker.SASTWorkflow
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: ".github/workflows/*",
CaseSensitive: false,
}, searchGitHubActionWorkflowUseRegex, &workflowPaths, usesRegex)
if err != nil {
return sastWorkflows, err
}
for _, path := range workflowPaths {
sastWorkflow := checker.SASTWorkflow{
File: checker.File{
Path: path,
Offset: checker.OffsetDefault,
Type: finding.FileTypeSource,
},
Type: checkerType,
}
sastWorkflows = append(sastWorkflows, sastWorkflow)
}
return sastWorkflows, nil
}
var searchGitHubActionWorkflowUseRegex fileparser.DoWhileTrueOnFileContent = func(path string,
content []byte,
args ...interface{},
) (bool, error) {
if !fileparser.IsWorkflowFile(path) {
return true, nil
}
if len(args) != 2 {
return false, fmt.Errorf(
"searchGitHubActionWorkflowUseRegex requires exactly 2 arguments: %w", errInvalid)
}
// Verify the type of the data.
paths, ok := args[0].(*[]string)
if !ok {
return false, fmt.Errorf(
"searchGitHubActionWorkflowUseRegex expects arg[0] of type *[]string: %w", errInvalid)
}
usesRegex, ok := args[1].(string)
if !ok {
return false, fmt.Errorf(
"searchGitHubActionWorkflowUseRegex expects arg[1] of type string: %w", errInvalid)
}
workflow, errs := actionlint.Parse(content)
if len(errs) > 0 && workflow == nil {
return false, fileparser.FormatActionlintError(errs)
}
for _, job := range workflow.Jobs {
for _, step := range job.Steps {
e, ok := step.Exec.(*actionlint.ExecAction)
if !ok || e == nil || e.Uses == nil {
continue
}
// Parse out repo / SHA.
uses := strings.TrimPrefix(e.Uses.Value, "actions://")
action, _, _ := strings.Cut(uses, "@")
re := regexp.MustCompile(usesRegex)
if re.MatchString(action) {
*paths = append(*paths, path)
}
}
}
return true, nil
}
type sonarConfig struct {
url string
file checker.File
}
func getSonarWorkflows(c *checker.CheckRequest) ([]checker.SASTWorkflow, error) {
var config []sonarConfig
var sastWorkflows []checker.SASTWorkflow
// in the future, we may want to use ListFiles instead, so we don't open every file
err := fileparser.OnMatchingFileReaderDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "*",
CaseSensitive: false,
}, validateSonarConfig, &config)
if err != nil {
return sastWorkflows, err
}
for _, result := range config {
sastWorkflow := checker.SASTWorkflow{
File: checker.File{
Path: result.file.Path,
Offset: result.file.Offset,
EndOffset: result.file.EndOffset,
Type: result.file.Type,
Snippet: result.url,
},
Type: checker.SonarWorkflow,
}
sastWorkflows = append(sastWorkflows, sastWorkflow)
}
return sastWorkflows, nil
}
// Check file content.
var validateSonarConfig fileparser.DoWhileTrueOnFileReader = func(pathfn string,
reader io.Reader,
args ...interface{},
) (bool, error) {
if !strings.EqualFold(path.Base(pathfn), "pom.xml") {
return true, nil
}
if len(args) != 1 {
return false, fmt.Errorf(
"validateSonarConfig requires exactly 1 argument: %w", errInvalid)
}
// Verify the type of the data.
pdata, ok := args[0].(*[]sonarConfig)
if !ok {
return false, fmt.Errorf(
"validateSonarConfig expects arg[0] of type *[]sonarConfig]: %w", errInvalid)
}
content, err := io.ReadAll(reader)
if err != nil {
return false, fmt.Errorf("read file: %w", err)
}
regex := regexp.MustCompile(`<sonar\.host\.url>\s*(\S+)\s*<\/sonar\.host\.url>`)
match := regex.FindSubmatch(content)
if len(match) < 2 {
return true, nil
}
offset, err := findLine(content, []byte("<sonar.host.url>"))
if err != nil {
return false, err
}
endOffset, err := findLine(content, []byte("</sonar.host.url>"))
if err != nil {
return false, err
}
*pdata = append(*pdata, sonarConfig{
url: string(match[1]),
file: checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: offset,
EndOffset: endOffset,
},
})
return true, nil
}
func findLine(content, data []byte) (uint, error) {
r := bytes.NewReader(content)
scanner := bufio.NewScanner(r)
var line uint
for scanner.Scan() {
line++
if bytes.Contains(scanner.Bytes(), data) {
return line, nil
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("scanner.Err(): %w", err)
}
return 0, nil
}
// Copyright 2024 OpenSSF 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 raw
import (
"errors"
"fmt"
"regexp"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
)
var (
reRootFile = regexp.MustCompile(`^[^.]([^//]*)$`)
reSBOMFile = regexp.MustCompile(
`(?i).+\.(cdx.json|cdx.xml|spdx|spdx.json|spdx.xml|spdx.y[a?]ml|spdx.rdf|spdx.rdf.xml)`,
)
)
const releaseLookBack = 5
// SBOM retrieves the raw data for the SBOM check.
func SBOM(c *checker.CheckRequest) (checker.SBOMData, error) {
var results checker.SBOMData
releases, lerr := c.RepoClient.ListReleases()
if lerr != nil && !errors.Is(lerr, clients.ErrUnsupportedFeature) {
return results, fmt.Errorf("RepoClient.ListReleases: %w", lerr)
}
results.SBOMFiles = append(results.SBOMFiles, checkSBOMReleases(releases)...)
// Look for SBOMs in source
repoFiles, err := c.RepoClient.ListFiles(func(file string) (bool, error) {
return reSBOMFile.MatchString(file) && reRootFile.MatchString(file), nil
})
if err != nil {
return results, fmt.Errorf("error during ListFiles: %w", err)
}
results.SBOMFiles = append(results.SBOMFiles, checkSBOMSource(repoFiles)...)
return results, nil
}
func checkSBOMReleases(releases []clients.Release) []checker.SBOM {
var foundSBOMs []checker.SBOM
for i := range releases {
if i >= releaseLookBack {
break
}
v := releases[i]
for _, link := range v.Assets {
if !reSBOMFile.MatchString(link.Name) {
continue
}
foundSBOMs = append(foundSBOMs,
checker.SBOM{
File: checker.File{
Path: link.URL,
Type: finding.FileTypeURL,
},
Name: link.Name,
})
// Only want one sbom from each release
break
}
}
return foundSBOMs
}
func checkSBOMSource(fileList []string) []checker.SBOM {
var foundSBOMs []checker.SBOM
for _, file := range fileList {
// TODO: parse matching file contents to determine schema & version
foundSBOMs = append(foundSBOMs,
checker.SBOM{
File: checker.File{
Path: file,
Type: finding.FileTypeSource,
},
Name: file,
})
}
return foundSBOMs
}
// Copyright 2020 OpenSSF 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 raw
import (
"bufio"
"errors"
"fmt"
"path"
"regexp"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
type securityPolicyFilesWithURI struct {
uri string
files []checker.SecurityPolicyFile
}
// SecurityPolicy checks for presence of security policy
// and applicable content discovered by checkSecurityPolicyFileContent().
func SecurityPolicy(c *checker.CheckRequest) (checker.SecurityPolicyData, error) {
data := securityPolicyFilesWithURI{
uri: "", files: make([]checker.SecurityPolicyFile, 0),
}
err := fileparser.OnAllFilesDo(c.RepoClient, isSecurityPolicyFile, &data)
if err != nil {
return checker.SecurityPolicyData{}, err
}
// If we found files in the repo, return immediately.
if len(data.files) > 0 {
for idx := range data.files {
err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: data.files[idx].File.Path,
CaseSensitive: false,
}, checkSecurityPolicyFileContent, &data.files[idx].File, &data.files[idx].Information)
if err != nil {
return checker.SecurityPolicyData{}, err
}
}
return checker.SecurityPolicyData{PolicyFiles: data.files}, nil
}
// Check if present in parent org.
// https#://docs.github.com/en/github/building-a-strong-community/creating-a-default-community-health-file.
client, err := c.RepoClient.GetOrgRepoClient(c.Ctx)
switch {
case err == nil:
defer client.Close()
data.uri = client.URI()
err = fileparser.OnAllFilesDo(client, isSecurityPolicyFile, &data)
if err != nil {
return checker.SecurityPolicyData{}, fmt.Errorf("unable to create github client: %w", err)
}
case errors.Is(err, sce.ErrRepoUnreachable), errors.Is(err, clients.ErrUnsupportedFeature):
break
default:
return checker.SecurityPolicyData{}, err
}
// Return raw results.
if len(data.files) > 0 {
for idx := range data.files {
filePattern := data.files[idx].File.Path
// undo path.Join in isSecurityPolicyFile just
// for this call to OnMatchingFileContentsDo
if data.files[idx].File.Type == finding.FileTypeURL {
filePattern = strings.Replace(filePattern, data.uri+"/", "", 1)
}
err := fileparser.OnMatchingFileContentDo(client, fileparser.PathMatcher{
Pattern: filePattern,
CaseSensitive: false,
}, checkSecurityPolicyFileContent, &data.files[idx].File, &data.files[idx].Information)
if err != nil {
return checker.SecurityPolicyData{}, err
}
}
}
return checker.SecurityPolicyData{PolicyFiles: data.files}, nil
}
// Check repository for repository-specific policy.
// https://docs.github.com/en/github/building-a-strong-community/creating-a-default-community-health-file.
var isSecurityPolicyFile fileparser.DoWhileTrueOnFilename = func(name string, args ...interface{}) (bool, error) {
if len(args) != 1 {
return false, fmt.Errorf("isSecurityPolicyFile requires exactly one argument: %w", errInvalidArgLength)
}
pdata, ok := args[0].(*securityPolicyFilesWithURI)
if !ok {
return false, fmt.Errorf("invalid arg type: %w", errInvalidArgType)
}
if isSecurityPolicyFilename(name) {
tempPath := name
tempType := finding.FileTypeText
if pdata.uri != "" {
// report complete path for org-based policy files
tempPath = path.Join(pdata.uri, tempPath)
// FileTypeURL is used in Security-Policy to
// only denote for the details report that the
// policy was found at the org level rather
// than the repo level
tempType = finding.FileTypeURL
}
pdata.files = append(pdata.files, checker.SecurityPolicyFile{
File: checker.File{
Path: tempPath,
Type: tempType,
Offset: checker.OffsetDefault,
FileSize: checker.OffsetDefault,
},
Information: make([]checker.SecurityPolicyInformation, 0),
})
// TODO: change 'false' to 'true' when multiple security policy files are supported
// otherwise this check stops at the first security policy found
return false, nil
}
return true, nil
}
func isSecurityPolicyFilename(name string) bool {
return strings.EqualFold(name, "security.md") ||
strings.EqualFold(name, ".github/security.md") ||
strings.EqualFold(name, "docs/security.md") ||
strings.EqualFold(name, "security.markdown") ||
strings.EqualFold(name, ".github/security.markdown") ||
strings.EqualFold(name, "docs/security.markdown") ||
strings.EqualFold(name, "security.adoc") ||
strings.EqualFold(name, ".github/security.adoc") ||
strings.EqualFold(name, "docs/security.adoc") ||
strings.EqualFold(name, "security.rst") ||
strings.EqualFold(name, ".github/security.rst") ||
strings.EqualFold(name, "doc/security.rst") ||
strings.EqualFold(name, "docs/security.rst")
}
var checkSecurityPolicyFileContent fileparser.DoWhileTrueOnFileContent = func(path string, content []byte,
args ...interface{},
) (bool, error) {
if len(args) != 2 {
return false, fmt.Errorf(
"checkSecurityPolicyFileContent requires exactly two arguments: %w", errInvalidArgLength)
}
pfiles, ok := args[0].(*checker.File)
if !ok {
return false, fmt.Errorf(
"checkSecurityPolicyFileContent requires argument of type *checker.File: %w", errInvalidArgType)
}
pinfo, ok := args[1].(*[]checker.SecurityPolicyInformation)
if !ok {
return false, fmt.Errorf(
"%s requires argument of type *[]checker.SecurityPolicyInformation: %w",
"checkSecurityPolicyFileContent", errInvalidArgType)
}
if len(content) == 0 {
// perhaps there are more policy files somewhere else,
// keep looking (true)
return true, nil
}
if pfiles != nil && (*pinfo) != nil {
pfiles.Offset = checker.OffsetDefault
pfiles.FileSize = uint(len(content))
policyHits := collectPolicyHits(content)
if len(policyHits) > 0 {
(*pinfo) = append((*pinfo), policyHits...)
}
} else {
e := sce.WithMessage(sce.ErrScorecardInternal, "bad file or information reference")
return false, e
}
// stop here found something, no need to look further (false)
return false, nil
}
func collectPolicyHits(policyContent []byte) []checker.SecurityPolicyInformation {
var hits []checker.SecurityPolicyInformation
// pattern for URLs
reURL := regexp.MustCompile(`(http|https)://[a-zA-Z0-9./?=_%:-]*`)
// pattern for emails
reEML := regexp.MustCompile(`\b[A-Za-z0-9._%+-]+(@|\\?\[at\\?\])[A-Za-z0-9.-]+\.[A-Za-z]{2,6}\b`)
// pattern for 1 to 4 digit numbers
// or
// strings 'disclos' as in "disclosure" or 'vuln' as in "vulnerability"
reDIG := regexp.MustCompile(`(?i)(\b*[0-9]{1,4}\b|(Disclos|Vuln))`)
lineNum := 0
for {
advance, token, err := bufio.ScanLines(policyContent, true)
if advance == 0 || err != nil {
break
}
lineNum += 1
if len(token) != 0 {
for _, indexes := range reURL.FindAllIndex(token, -1) {
hits = append(hits, checker.SecurityPolicyInformation{
InformationType: checker.SecurityPolicyInformationTypeLink,
InformationValue: checker.SecurityPolicyValueType{
Match: string(token[indexes[0]:indexes[1]]), // Snippet of match
LineNumber: uint(lineNum), // line number in file
Offset: uint(indexes[0]), // Offset in the line
},
})
}
for _, indexes := range reEML.FindAllIndex(token, -1) {
hits = append(hits, checker.SecurityPolicyInformation{
InformationType: checker.SecurityPolicyInformationTypeEmail,
InformationValue: checker.SecurityPolicyValueType{
Match: string(token[indexes[0]:indexes[1]]), // Snippet of match
LineNumber: uint(lineNum), // line number in file
Offset: uint(indexes[0]), // Offset in the line
},
})
}
for _, indexes := range reDIG.FindAllIndex(token, -1) {
hits = append(hits, checker.SecurityPolicyInformation{
InformationType: checker.SecurityPolicyInformationTypeText,
InformationValue: checker.SecurityPolicyValueType{
Match: string(token[indexes[0]:indexes[1]]), // Snippet of match
LineNumber: uint(lineNum), // line number in file
Offset: uint(indexes[0]), // Offset in the line
},
})
}
}
if advance <= len(policyContent) {
policyContent = policyContent[advance:]
}
}
return hits
}
// Copyright 2021 OpenSSF 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 raw
import (
"bufio"
"bytes"
"errors"
"fmt"
"net/url"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"mvdan.cc/sh/v3/syntax"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
var (
// supportedShells is the list of shells that are supported by mvdan.cc/sh/v3/syntax.
supportedShells = []string{
"sh", "bash", "mksh",
}
// otherShells are not supported by our parser.
otherShells = []string{
"dash", "ksh",
}
shellNames = append(supportedShells, otherShells...)
pythonInterpreters = []string{"python", "python3", "python2.7"}
shellInterpreters = append([]string{"exec", "su"}, shellNames...)
otherInterpreters = []string{"perl", "ruby", "php", "node", "nodejs", "java"}
dotnetInterpreters = []string{"dotnet", "nuget"}
interpreters = append(dotnetInterpreters, append(otherInterpreters,
append(shellInterpreters, append(shellNames, pythonInterpreters...)...)...)...)
)
// Note: aws is handled separately because it uses different
// cli options.
var downloadUtils = []string{
"curl", "wget", "gsutil",
}
var gitCommitHashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
func isBinaryName(expected, name string) bool {
return strings.EqualFold(path.Base(name), expected)
}
func isExecuteFile(cmd []string, fn string) bool {
if len(cmd) == 0 {
return false
}
return strings.EqualFold(filepath.Clean(cmd[0]), filepath.Clean(fn))
}
// see https://serverfault.com/questions/226386/wget-a-script-and-run-it/890417.
func isDownloadUtility(cmd []string) bool {
if len(cmd) == 0 {
return false
}
// Note: we won't be catching those if developers have re-named
// the utility.
// Note: wget -O - <website>, but we don't check for that explicitly.
for _, b := range downloadUtils {
if isBinaryName(b, cmd[0]) {
return true
}
}
// aws s3api get-object.
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/download-objects.html.
if isBinaryName("aws", cmd[0]) {
if len(cmd) >= 3 && strings.EqualFold("s3api", cmd[1]) && strings.EqualFold("get-object", cmd[2]) {
return true
}
}
return false
}
func getWgetOutputFile(cmd []string) (pathfn string, ok bool, err error) {
if isBinaryName("wget", cmd[0]) {
for i := 1; i < len(cmd)-1; i++ {
// Find -O output, or use the basename from url.
if strings.EqualFold(cmd[i], "-O") {
return cmd[i+1], true, nil
}
}
// Could not find -O option, use the url's name instead.
for i := 1; i < len(cmd); i++ {
if !strings.HasPrefix(cmd[i], "http") {
continue
}
u, err := url.Parse(cmd[i])
if err != nil {
return "", false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}
return path.Base(u.Path), true, nil
}
}
return "", false, nil
}
func getGsutilOutputFile(cmd []string) (pathfn string, ok bool, err error) {
if isBinaryName("gsutil", cmd[0]) {
for i := 1; i < len(cmd)-1; i++ {
if !strings.HasPrefix(cmd[i], "gs://") {
continue
}
pathfn := cmd[i+1]
if filepath.Clean(filepath.Dir(pathfn)) == filepath.Clean(pathfn) {
// Directory.
u, err := url.Parse(cmd[i])
if err != nil {
return "", false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}
return filepath.Join(filepath.Dir(pathfn), path.Base(u.Path)), true, nil
}
// File provided.
return pathfn, true, nil
}
}
return "", false, nil
}
func getAWSOutputFile(cmd []string) (pathfn string, ok bool, err error) {
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/download-objects.html.
if isBinaryName("aws", cmd[0]) {
if len(cmd) < 3 || !strings.EqualFold("s3api", cmd[1]) || !strings.EqualFold("get-object", cmd[2]) {
return "", false, nil
}
// Just take the last 2 arguments.
ifile := cmd[len(cmd)-2]
ofile := cmd[len(cmd)-1]
if filepath.Clean(filepath.Dir(ofile)) == filepath.Clean(ofile) {
u, err := url.Parse(ifile)
if err != nil {
return "", false, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}
return filepath.Join(filepath.Dir(ofile), path.Base(u.Path)), true, nil
}
// File provided.
return ofile, true, nil
}
return "", false, nil
}
func getOutputFile(cmd []string) (pathfn string, ok bool, err error) {
if len(cmd) == 0 {
return "", false, nil
}
// Wget.
fn, b, err := getWgetOutputFile(cmd)
if err != nil || b {
return fn, b, err
}
// Gsutil.
fn, b, err = getGsutilOutputFile(cmd)
if err != nil || b {
return fn, b, err
}
// Aws.
fn, b, err = getAWSOutputFile(cmd)
if err != nil || b {
return fn, b, err
}
// TODO(laurent): add other cloud services' utilities
return "", false, nil
}
func isInterpreter(cmd []string) bool {
if len(cmd) == 0 {
return false
}
for _, b := range interpreters {
if isBinaryName(b, cmd[0]) {
return true
}
}
return false
}
func isShellInterpreterOrCommand(cmd []string) bool {
if len(cmd) == 0 {
return false
}
if isPythonCommand(cmd) {
return false
}
for _, b := range otherInterpreters {
if isBinaryName(b, cmd[0]) {
return false
}
}
return true
}
func extractInterpreterAndCommand(cmd []string) (string, bool) {
if len(cmd) == 0 {
return "", false
}
for _, b := range interpreters {
if isCommand(cmd, b) {
return b, true
}
}
return "", false
}
func isInterpreterWithFile(cmd []string, fn string) bool {
if len(cmd) == 0 {
return false
}
for _, b := range interpreters {
if !isBinaryName(b, cmd[0]) {
continue
}
for _, arg := range cmd[1:] {
if strings.EqualFold(filepath.Clean(arg), filepath.Clean(fn)) {
return true
}
}
}
return false
}
func extractCommand(cmd interface{}) ([]string, bool) {
c, ok := cmd.(*syntax.CallExpr)
if !ok {
return nil, ok
}
var ret []string
for _, w := range c.Args {
if len(w.Parts) != 1 {
continue
}
switch v := w.Parts[0].(type) {
default:
continue
case *syntax.SglQuoted:
ret = append(ret, "'"+v.Value+"'")
case *syntax.DblQuoted:
if len(v.Parts) != 1 {
continue
}
lit, ok := v.Parts[0].(*syntax.Lit)
if ok {
ret = append(ret, "\""+lit.Value+"\"")
}
case *syntax.Lit:
if !strings.EqualFold(v.Value, "sudo") {
ret = append(ret, v.Value)
}
}
}
return ret, true
}
func getLine(startLine, endLine uint, node syntax.Node) (uint, uint) {
// endLine of 0 means it's unknown, in which case we re-use the startLine.
if endLine >= startLine {
return startLine + node.Pos().Line(),
endLine + node.Pos().Line()
}
return startLine + node.Pos().Line(),
startLine + node.Pos().Line()
}
func hasUnpinnedURLs(cmd []string) bool {
var urls []*url.URL
// Extract any URLs passed to the download utility
for _, s := range cmd {
u, err := url.ParseRequestURI(s)
if err == nil {
urls = append(urls, u)
}
}
// Look for any URLs which are pinned to a GitHub SHA
var pinned []*url.URL
for _, u := range urls {
// Look for a URL of the form: https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}
if u.Scheme == "https" && u.Host == "raw.githubusercontent.com" {
segments := strings.Split(u.Path, "/")
if len(segments) > 4 && gitCommitHashRegex.MatchString(segments[3]) {
pinned = append(pinned, u)
}
}
}
if len(pinned) > 0 && len(urls) == len(pinned) {
return false
}
return true
}
func collectFetchPipeExecute(startLine, endLine uint, node syntax.Node, cmd, pathfn string,
r *checker.PinningDependenciesData,
) {
// BinaryCmd {Op=|, X=CallExpr{Args={curl, -s, url}}, Y=CallExpr{Args={bash,}}}.
bc, ok := node.(*syntax.BinaryCmd)
if !ok {
return
}
// Look for the pipe operator.
if !strings.EqualFold(bc.Op.String(), "|") {
return
}
leftStmt, ok := extractCommand(bc.X.Cmd)
if !ok {
return
}
rightStmt, ok := extractCommand(bc.Y.Cmd)
if !ok {
return
}
if !isDownloadUtility(leftStmt) {
return
}
if !isInterpreter(rightStmt) {
return
}
if !hasUnpinnedURLs(leftStmt) {
return
}
startLine, endLine = getLine(startLine, endLine, node)
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(false),
Type: checker.DependencyUseTypeDownloadThenRun,
},
)
}
func getRedirectFile(red []*syntax.Redirect) (string, bool) {
if len(red) == 0 {
return "", false
}
for _, r := range red {
if !strings.EqualFold(r.Op.String(), ">") {
continue
}
if len(r.Word.Parts) != 1 {
continue
}
lit, ok := r.Word.Parts[0].(*syntax.Lit)
if ok {
return lit.Value, true
}
}
return "", false
}
func collectExecuteFiles(startLine, endLine uint, node syntax.Node, cmd, pathfn string, files map[string]bool,
r *checker.PinningDependenciesData,
) {
ce, ok := node.(*syntax.CallExpr)
if !ok {
return
}
c, ok := extractCommand(ce)
if !ok {
return
}
startLine, endLine = getLine(startLine, endLine, node)
for fn := range files {
if isInterpreterWithFile(c, fn) || isExecuteFile(c, fn) {
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(false),
Type: checker.DependencyUseTypeDownloadThenRun,
},
)
}
}
}
// Npm install docs are here.
// https://docs.npmjs.com/cli/v7/commands/npm-install
func isNpmDownload(cmd []string) bool {
if !isBinaryName("npm", cmd[0]) {
return false
}
for i := 1; i < len(cmd); i++ {
// Search for get/install/update commands.
if strings.EqualFold(cmd[i], "install") ||
strings.EqualFold(cmd[i], "i") ||
strings.EqualFold(cmd[i], "install-test") ||
strings.EqualFold(cmd[i], "update") ||
strings.EqualFold(cmd[i], "ci") {
return true
}
}
return false
}
func isNpmUnpinnedDownload(cmd []string) bool {
for i := 1; i < len(cmd); i++ {
// `npm ci` will verify all hashes are present.
if strings.EqualFold(cmd[i], "ci") {
return false
}
}
return true
}
func isGoDownload(cmd []string) bool {
// `Go install` will automatically look up the
// go.mod and go.sum, so we don't flag it.
if len(cmd) <= 2 {
return false
}
return isBinaryName("go", cmd[0]) && slices.Contains([]string{"get", "install"}, cmd[1])
}
func isGoUnpinnedDownload(cmd []string) bool {
insecure := false
hashRegex := regexp.MustCompile("^[A-Fa-f0-9]{40,}$")
semverRegex := regexp.MustCompile(`^v\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$`)
for i := 1; i < len(cmd)-1; i++ {
// Skip all flags
// TODO skip other build flags which might take arguments
for i < len(cmd)-1 && slices.Contains([]string{"-d", "-f", "-t", "-u", "-v", "-fix", "-insecure"}, cmd[i+1]) {
// Record the flag -insecure
if cmd[i+1] == "-insecure" {
insecure = true
}
i++
}
if i+1 >= len(cmd) {
// this is case go get -d -v
return false
}
// TODO check more than one package
pkg := cmd[i+1]
// Consider strings that are not URLs as local folders
// which are pinned.
regex := regexp.MustCompile(`\w+\.\w+/\w+`)
if !regex.MatchString(pkg) {
return false
}
// Verify pkg = name@hash
parts := strings.Split(pkg, "@")
if len(parts) != 2 {
continue
}
version := parts[1]
/*
"none" is special. It removes a dependency. Hashes are always okay. Full semantic versions are okay
as long as "-insecure" is not passed.
*/
if version == "none" || hashRegex.MatchString(version) || (!insecure && semverRegex.MatchString(version)) {
return false
}
}
return true
}
func isPipInstall(cmd []string) bool {
if len(cmd) < 2 {
return false
}
return (isBinaryName("pip", cmd[0]) || isBinaryName("pip3", cmd[0])) && strings.EqualFold(cmd[1], "install")
}
func isPinnedEditableSource(pkgSource string) bool {
regexRemoteSource := regexp.MustCompile(`^(git|svn|hg|bzr).+$`)
// Is from local source
if !regexRemoteSource.MatchString(pkgSource) {
return true
}
// Is VCS install from Git and it's pinned
// https://pip.pypa.io/en/latest/topics/vcs-support/#vcs-support
regexGitSource := regexp.MustCompile(`^git(\+(https?|ssh|git))?\:\/\/.*(.git)?@[a-fA-F0-9]{40}(#egg=.*)?$`)
return regexGitSource.MatchString(pkgSource)
// Disclaimer: We are not handling if Subversion (svn),
// Mercurial (hg) or Bazaar (bzr) remote sources are pinned
// because they are not common on GitHub repos
}
func isFlag(cmd string) bool {
regexFlag := regexp.MustCompile(`^(\-\-?\w+)+$`)
return regexFlag.MatchString(cmd)
}
func isUnpinnedPipInstall(cmd []string) bool {
hasNoDeps := false
isEditableInstall := false
isPinnedEditableInstall := true
hasRequireHashes := false
hasAdditionalArgs := false
hasWheel := false
for i := 2; i < len(cmd); i++ {
// Require --no-deps to not install the dependencies when doing editable install
// because we can't verify if dependencies are pinned
// https://pip.pypa.io/en/stable/topics/secure-installs/#do-not-use-setuptools-directly
// https://github.com/pypa/pip/issues/4995
if strings.EqualFold(cmd[i], "--no-deps") {
hasNoDeps = true
continue
}
// https://pip.pypa.io/en/stable/cli/pip_install/#cmdoption-e
if slices.Contains([]string{"-e", "--editable"}, cmd[i]) {
isEditableInstall = true
continue
}
// https://github.com/ossf/scorecard/issues/1306#issuecomment-974539197.
if strings.EqualFold(cmd[i], "--require-hashes") {
hasRequireHashes = true
break
}
// Catch not handled flags, otherwise is package
if isFlag(cmd[i]) {
continue
}
// Wheel package
// Exclude *.whl as they're mostly used
// for tests. See https://github.com/ossf/scorecard/pull/611.
if strings.HasSuffix(cmd[i], ".whl") {
// We continue because a command may contain
// multiple packages to install, not just `.whl` files.
hasWheel = true
continue
}
// Editable install package source
if isEditableInstall {
isPinned := isPinnedEditableSource(cmd[i])
if !isPinned {
isPinnedEditableInstall = false
}
continue
}
hasAdditionalArgs = true
}
// --require-hashes and -e flags cannot be used together in pip install
// -e and *.whl package cannot be used together in pip install
// If is editable install, it's secure if package is from local source
// or from remote (VCS install) pinned by hash, and if dependencies are
// not installed.
// Example: `pip install --no-deps -e git+https://git.repo/some_pkg.git@da39a3ee5e6b4b0d3255bfef95601890afd80709`
if isEditableInstall {
return !hasNoDeps || !isPinnedEditableInstall
}
// If hashes are required, it's pinned.
if hasRequireHashes {
return false
}
// With additional arguments, it's unpinned.
// Example: `pip install bla.whl pkg1`
if hasAdditionalArgs {
return true
}
// No additional arguments and hashes are not required.
// The only pinned command is `pip install *.whl`
if hasWheel {
return false
}
// Any other form of install is unpinned,
// e.g. `pip install`.
return true
}
func isPythonCommand(cmd []string) bool {
for _, pi := range pythonInterpreters {
if isBinaryName(pi, cmd[0]) {
return true
}
}
return false
}
func extractPipCommand(cmd []string) ([]string, bool) {
if len(cmd) == 0 {
return nil, false
}
for i := 1; i < len(cmd); i++ {
// Search for pip module.
if strings.EqualFold(cmd[i], "-m") &&
i < len(cmd)-1 &&
strings.EqualFold(cmd[i+1], "pip") {
return cmd[i+1:], true
}
}
return nil, false
}
func isPythonPipInstall(cmd []string) bool {
if !isPythonCommand(cmd) {
return false
}
pipCommand, ok := extractPipCommand(cmd)
if !ok {
return false
}
return isPipInstall(pipCommand)
}
func isUnpinnedPythonPipInstall(cmd []string) bool {
pipCommand, _ := extractPipCommand(cmd)
return isUnpinnedPipInstall(pipCommand)
}
func isPipDownload(cmd []string) bool {
return isPipInstall(cmd) || isPythonPipInstall(cmd)
}
func isPipUnpinnedDownload(cmd []string) bool {
if isPipInstall(cmd) && isUnpinnedPipInstall(cmd) {
return true
}
if isPythonPipInstall(cmd) && isUnpinnedPythonPipInstall(cmd) {
return true
}
return false
}
func isChocoDownload(cmd []string) bool {
// Install command is in the form 'choco install ...'
if len(cmd) < 2 {
return false
}
return (isBinaryName("choco", cmd[0]) || isBinaryName("choco.exe", cmd[0])) && strings.EqualFold(cmd[1], "install")
}
func isChocoUnpinnedDownload(cmd []string) bool {
// If this is an install command, then some variant of requirechecksum must be present.
for i := 2; i < len(cmd); i++ {
parts := strings.Split(cmd[i], "=")
if len(parts) == 0 {
continue
}
str := parts[0]
if strings.EqualFold(str, "--requirechecksum") ||
strings.EqualFold(str, "--requirechecksums") ||
strings.EqualFold(str, "--require-checksums") {
return false
}
}
return true
}
func isNugetCliInstall(cmd []string) bool {
// looking for command of type nuget install ...
if len(cmd) < 2 {
return false
}
// Search for nuget install commands.
return (isBinaryName("nuget", cmd[0]) || isBinaryName("nuget.exe", cmd[0])) && strings.EqualFold(cmd[1], "install")
}
func isUnpinnedNugetCliInstall(cmd []string) bool {
// Assume installing a project with PackageReference (with versions)
// or packages.config at the root of command
if len(cmd) == 2 {
return false
}
// Assume that the script is installing from a packages.config file (with versions)
// package.config schema has required version field
// https://learn.microsoft.com/en-us/nuget/reference/packages-config#schema
// and Nuget follows Semantic Versioning 2.0.0 (versions are immutable)
// https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#semantic-versioning-200
if strings.HasSuffix(cmd[2], "packages.config") {
return false
}
unpinnedDependency := true
for i := 2; i < len(cmd); i++ {
// look for version flag
if strings.EqualFold(cmd[i], "-Version") {
unpinnedDependency = false
break
}
}
return unpinnedDependency
}
func isDotNetCliAdd(cmd []string) bool {
// Search for command of type dotnet add <PROJECT> package <PACKAGE_NAME>
if len(cmd) < 4 {
return false
}
// Search for dotnet add [PROJECT] package <PACKAGE_NAME>
// where package command can be either the second or the third word
return (isBinaryName("dotnet", cmd[0]) || isBinaryName("dotnet.exe", cmd[0])) &&
strings.EqualFold(cmd[1], "add") &&
(strings.EqualFold(cmd[2], "package") || strings.EqualFold(cmd[3], "package"))
}
func isUnpinnedDotNetCliAdd(cmd []string) bool {
unpinnedDependency := true
for i := 3; i < len(cmd); i++ {
// look for version flag
// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-add-package
if strings.EqualFold(cmd[i], "-v") || strings.EqualFold(cmd[i], "--version") {
unpinnedDependency = false
break
}
}
return unpinnedDependency
}
func isNuget(cmd []string) bool {
return isDotNetCliAdd(cmd) ||
isNugetCliInstall(cmd) ||
isDotNetCliRestore(cmd) ||
isNugetCliRestore(cmd) ||
isMsBuildRestore(cmd)
}
func isNugetUnpinned(cmd []string) bool {
if isDotNetCliAdd(cmd) && isUnpinnedDotNetCliAdd(cmd) {
return true
}
if isNugetCliInstall(cmd) && isUnpinnedNugetCliInstall(cmd) {
return true
}
if isDotNetCliRestore(cmd) && isUnpinnedDotNetCliRestore(cmd) {
return true
}
if isNugetCliRestore(cmd) && isUnpinnedNugetCliRestore(cmd) {
return true
}
if isMsBuildRestore(cmd) && isUnpinnedMsBuildCliRestore(cmd) {
return true
}
return false
}
func isNugetCliRestore(cmd []string) bool {
// Search for command of type nuget restore
if len(cmd) < 2 {
return false
}
// Search for nuget restore
return (isBinaryName("nuget", cmd[0]) || isBinaryName("nuget.exe", cmd[0])) &&
strings.EqualFold(cmd[1], "restore")
}
func isDotNetCliRestore(cmd []string) bool {
// Search for command of type dotnet restore
if len(cmd) < 2 {
return false
}
// Search for dotnet restore
return (isBinaryName("dotnet", cmd[0]) || isBinaryName("dotnet.exe", cmd[0])) &&
strings.EqualFold(cmd[1], "restore")
}
func isMsBuildRestore(cmd []string) bool {
// Search for command of type msbuild /t:restore
if len(cmd) < 2 {
return false
}
// Search for msbuild /t:restore
if isBinaryName("msbuild", cmd[0]) || isBinaryName("msbuild.exe", cmd[0]) {
for i := 1; i < len(cmd); i++ {
// look for /t:restore flag
if strings.EqualFold(cmd[i], "/t:restore") {
return true
}
}
}
return false
}
func isUnpinnedNugetCliRestore(cmd []string) bool {
unpinnedDependency := true
for i := 2; i < len(cmd); i++ {
// look for LockedMode flag
// https://learn.microsoft.com/en-us/nuget/reference/cli-reference/cli-ref-restore
if strings.EqualFold(cmd[i], "-LockedMode") {
unpinnedDependency = false
break
}
}
return unpinnedDependency
}
func isUnpinnedDotNetCliRestore(cmd []string) bool {
unpinnedDependency := true
for i := 2; i < len(cmd); i++ {
// look for locked-mode flag
// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-restore
if strings.EqualFold(cmd[i], "--locked-mode") {
unpinnedDependency = false
break
}
}
return unpinnedDependency
}
func isUnpinnedMsBuildCliRestore(cmd []string) bool {
unpinnedDependency := true
for i := 2; i < len(cmd); i++ {
// look for /p:RestoreLockedMode=true
// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-restore
if strings.EqualFold(cmd[i], "/p:RestoreLockedMode=true") {
unpinnedDependency = false
break
}
}
return unpinnedDependency
}
func collectUnpinnedPackageManagerDownload(startLine, endLine uint, node syntax.Node,
cmd, pathfn string, r *checker.PinningDependenciesData,
) {
ce, ok := node.(*syntax.CallExpr)
if !ok {
return
}
c, ok := extractCommand(ce)
if !ok {
return
}
startLine, endLine = getLine(startLine, endLine, node)
if len(c) == 0 {
return
}
// Go get/install.
if isGoDownload(c) {
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(!isGoUnpinnedDownload(c)),
Type: checker.DependencyUseTypeGoCommand,
},
)
return
}
// Pip install.
if isPipDownload(c) {
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(!isPipUnpinnedDownload(c)),
Type: checker.DependencyUseTypePipCommand,
},
)
return
}
// Npm install.
if isNpmDownload(c) {
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(!isNpmUnpinnedDownload(c)),
Type: checker.DependencyUseTypeNpmCommand,
},
)
return
}
// Choco install.
if isChocoDownload(c) {
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(!isChocoUnpinnedDownload(c)),
Type: checker.DependencyUseTypeChocoCommand,
},
)
return
}
// Nuget install and restore
if isNuget(c) {
pinned := !isNugetUnpinned(c)
var remediation *finding.Remediation
if !pinned {
remediation = &finding.Remediation{
Text: "pin your dependecies by either enabling central package management " +
"(https://learn.microsoft.com/nuget/consume-packages/Central-Package-Management) " +
"or using a lockfile (https://learn.microsoft.com/nuget/consume-packages/" +
"package-references-in-project-files#locking-dependencies)",
}
}
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: &pinned,
Type: checker.DependencyUseTypeNugetCommand,
Remediation: remediation,
})
return
}
// TODO(laurent): add other package managers.
}
func recordFetchFileFromNode(node syntax.Node) (pathfn string, ok bool, err error) {
ss, ok := node.(*syntax.Stmt)
if !ok {
return "", false, nil
}
cmd, ok := extractCommand(ss.Cmd)
if !ok {
return "", false, nil
}
if !isDownloadUtility(cmd) {
return "", false, nil
}
fn, ok := getRedirectFile(ss.Redirs)
if !ok {
return getOutputFile(cmd)
}
return fn, true, nil
}
func collectFetchProcSubsExecute(startLine, endLine uint, node syntax.Node, cmd, pathfn string,
r *checker.PinningDependenciesData,
) {
ce, ok := node.(*syntax.CallExpr)
if !ok {
return
}
c, ok := extractCommand(ce)
if !ok {
return
}
if !isInterpreter(c) {
return
}
// Now parse the process substitution part.
// Example: `bash <(wget -qO- http://website.com/my-script.sh)`.
l := 2
if len(ce.Args) < l {
return
}
parts := ce.Args[1].Parts
if len(parts) != 1 {
return
}
part := parts[0]
p, ok := part.(*syntax.ProcSubst)
if !ok {
return
}
if !strings.EqualFold(p.Op.String(), "<(") {
return
}
if len(p.Stmts) == 0 {
return
}
c, ok = extractCommand(p.Stmts[0].Cmd)
if !ok {
return
}
if !isDownloadUtility(c) {
return
}
startLine, endLine = getLine(startLine, endLine, node)
r.Dependencies = append(r.Dependencies,
checker.Dependency{
Location: &checker.File{
Path: pathfn,
Type: finding.FileTypeSource,
Offset: startLine,
EndOffset: endLine,
Snippet: cmd,
},
Pinned: asBoolPointer(false),
Type: checker.DependencyUseTypeDownloadThenRun,
},
)
}
func isCommand(cmd []string, b string) bool {
isBin := false
for _, c := range cmd {
if isBinaryName(b, c) {
isBin = true
} else if isBin && strings.HasPrefix(c, "-") && strings.Contains(c, "c") {
return true
}
}
return false
}
func extractInterpreterCommandFromArgs(args []*syntax.Word) (string, bool) {
for _, arg := range args {
if len(arg.Parts) != 1 {
continue
}
part := arg.Parts[0]
switch v := part.(type) {
case *syntax.DblQuoted:
if len(v.Parts) != 1 {
continue
}
lit, ok := v.Parts[0].(*syntax.Lit)
if !ok {
continue
}
return lit.Value, true
case *syntax.SglQuoted:
return v.Value, true
}
}
return "", false
}
func extractInterpreterAndCommandFromNode(node syntax.Node) (interpreter, command string, yes bool) {
ce, ok := node.(*syntax.CallExpr)
if !ok {
return "", "", false
}
c, ok := extractCommand(ce)
if !ok {
return "", "", false
}
i, ok := extractInterpreterAndCommand(c)
if !ok {
return "", "", false
}
cs, ok := extractInterpreterCommandFromArgs(ce.Args)
if !ok {
return "", "", false
}
return i, cs, true
}
func nodeToString(p *syntax.Printer, node syntax.Node) (string, error) {
// https://github.com/mvdan/sh/blob/24dd9930bc1cfc7be025f8b75b2e9e9f04524012/syntax/printer.go#L135.
var buf bytes.Buffer
err := p.Print(&buf, node)
// This is ugly, but the parser does not have a defined error type :/.
if err != nil && !strings.Contains(err.Error(), "unsupported node type") {
return "", sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("syntax.Printer.Print: %v", err))
}
return buf.String(), nil
}
func validateShellFileAndRecord(pathfn string, startLine, endLine uint, content []byte, files map[string]bool,
r *checker.PinningDependenciesData,
) error {
in := strings.NewReader(string(content))
f, err := syntax.NewParser().Parse(in, pathfn)
if err != nil {
// If we cannot parse the file, register that we are skipping it
var parseError syntax.ParseError
if errors.As(err, &parseError) {
content := string(content)
r.ProcessingErrors = append(r.ProcessingErrors, checker.ElementError{
Err: sce.WithMessage(sce.ErrShellParsing, parseError.Text),
Location: finding.Location{
Path: pathfn,
LineStart: &startLine,
LineEnd: &endLine,
Snippet: &content,
Type: finding.FileTypeSource,
},
})
return nil
}
return sce.WithMessage(sce.ErrShellParsing, err.Error())
}
printer := syntax.NewPrinter()
syntax.Walk(f, func(node syntax.Node) bool {
cmdStr, e := nodeToString(printer, node)
if e != nil {
err = e
return false
}
// interpreter -c "CMD".
i, c, ok := extractInterpreterAndCommandFromNode(node)
// TODO: support other interpreters.
// Example: https://github.com/apache/airflow/blob/main/scripts/ci/kubernetes/ci_run_kubernetes_tests.sh#L75
// HOST_PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")')``
if ok && isShellInterpreterOrCommand([]string{i}) {
start, end := getLine(startLine, endLine, node)
e := validateShellFileAndRecord(pathfn, start, end,
[]byte(c), files, r)
if e != nil {
err = e
return true
}
}
// `curl | bash` (supports `sudo`).
collectFetchPipeExecute(startLine, endLine, node, cmdStr, pathfn, r)
// Check if we're calling a file we previously downloaded.
// Includes `curl > /tmp/file [&&|;] [bash] /tmp/file`
collectExecuteFiles(startLine, endLine, node, cmdStr, pathfn, files, r)
// `bash <(wget -qO- http://website.com/my-script.sh)`. (supports `sudo`).
collectFetchProcSubsExecute(startLine, endLine, node, cmdStr, pathfn, r)
// Package manager's unpinned installs.
collectUnpinnedPackageManagerDownload(startLine, endLine, node, cmdStr, pathfn, r)
// TODO(laurent): add check for cat file | bash.
// TODO(laurent): detect downloads of zip/tar files containing scripts.
// TODO(laurent): detect command being an env variable.
// TODO(laurent): detect unpinned git clone.
// Record the file that is downloaded, if any.
fn, b, e := recordFetchFileFromNode(node)
if e != nil {
err = e
return false
} else if b {
files[fn] = true
}
// Continue walking the node graph.
return true
})
return err
}
// The functions below are the only ones that should be called by other files.
// There needs to be a call to extractInterpreterCommandFromString() prior
// to calling other functions.
func isSupportedShell(shellName string) bool {
for _, name := range supportedShells {
if isBinaryName(name, shellName) {
return true
}
}
return false
}
func isShellScriptFile(pathfn string, content []byte) bool {
return isMatchingShellScriptFile(pathfn, content, shellInterpreters)
}
// isSupportedShellScriptFile returns true if this file is one of the shell scripts we can parse. If a shebang
// is present in the file, the decision is based entirely on that, otherwise the file extension is used to decide.
func isSupportedShellScriptFile(pathfn string, content []byte) bool {
return isMatchingShellScriptFile(pathfn, content, supportedShells)
}
func isMatchingShellScriptFile(pathfn string, content []byte, shellsToMatch []string) bool {
// Determine if it matches the file extension first.
hasShellFileExtension := false
for _, name := range shellsToMatch {
// Look at the prefix.
if strings.HasSuffix(pathfn, "."+name) {
hasShellFileExtension = true
break
}
}
// Look at file content.
r := strings.NewReader(string(content))
scanner := bufio.NewScanner(r)
// TODO: support perl scripts with embedded shell scripts:
// https://github.com/openssl/openssl/blob/master/test/recipes/15-test_dsaparam.t.
// Only look at first line.
if !scanner.Scan() {
return hasShellFileExtension
}
line := scanner.Text()
// #!/bin/XXX, #!XXX, #!/usr/bin/env XXX, #!env XXX
if !strings.HasPrefix(line, "#!") {
// If there's no shebang, go off the file extension.
return hasShellFileExtension
}
line = line[2:]
for _, name := range shellsToMatch {
parts := strings.Split(line, " ")
// #!/bin/bash, #!bash -e
if len(parts) >= 1 && isBinaryName(name, parts[0]) {
return true
}
// #!/bin/env bash
if len(parts) >= 2 &&
isBinaryName("env", parts[0]) &&
isBinaryName(name, parts[1]) {
return true
}
}
return false // It has a shebang, but it's not one of our matching shells.
}
func validateShellFile(pathfn string, startLine, endLine uint,
content []byte, taintedFiles map[string]bool, r *checker.PinningDependenciesData,
) error {
return validateShellFileAndRecord(pathfn, startLine, endLine, content, taintedFiles, r)
}
// Copyright 2020 OpenSSF 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 raw
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
)
// SignedReleases checks for presence of signed release check.
func SignedReleases(c *checker.CheckRequest) (checker.SignedReleasesData, error) {
releases, err := c.RepoClient.ListReleases()
if err != nil {
return checker.SignedReleasesData{}, fmt.Errorf("%w", err)
}
pkgs := []checker.ProjectPackage{}
versions, err := c.ProjectClient.GetProjectPackageVersions(c.Ctx, c.Repo.Host(), c.Repo.Path())
if err != nil {
if c.Dlogger != nil {
c.Dlogger.Debug(&checker.LogMessage{Text: fmt.Sprintf("GetProjectPackageVersions: %v", err)})
}
return checker.SignedReleasesData{
Releases: releases,
Packages: pkgs,
}, nil
}
for _, v := range versions.Versions {
prov := checker.PackageProvenance{}
if len(v.SLSAProvenances) > 0 {
prov = checker.PackageProvenance{
Commit: v.SLSAProvenances[0].Commit,
IsVerified: v.SLSAProvenances[0].Verified,
}
}
pkgs = append(pkgs, checker.ProjectPackage{
System: v.VersionKey.System,
Name: v.VersionKey.Name,
Version: v.VersionKey.Version,
Provenance: prov,
})
}
return checker.SignedReleasesData{
Releases: releases,
Packages: pkgs,
}, nil
}
// Copyright 2022 OpenSSF 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 raw
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
)
// Vulnerabilities retrieves the raw data for the Vulnerabilities check.
func Vulnerabilities(c *checker.CheckRequest) (checker.VulnerabilitiesData, error) {
commitHash := ""
commits, err := c.RepoClient.ListCommits()
if err == nil && len(commits) > 0 && !allOf(commits, hasEmptySHA) {
commitHash = commits[0].SHA
}
localPath, err := c.RepoClient.LocalPath()
if err != nil {
return checker.VulnerabilitiesData{}, fmt.Errorf("RepoClient.LocalPath: %w", err)
}
resp, err := c.VulnerabilitiesClient.ListUnfixedVulnerabilities(c.Ctx, commitHash, localPath)
if err != nil {
return checker.VulnerabilitiesData{}, fmt.Errorf("vulnerabilitiesClient.ListUnfixedVulnerabilities: %w", err)
}
return checker.VulnerabilitiesData{
Vulnerabilities: resp.Vulnerabilities,
}, nil
}
type predicateOnCommitFn func(clients.Commit) bool
var hasEmptySHA predicateOnCommitFn = func(c clients.Commit) bool {
return c.SHA == ""
}
func allOf(commits []clients.Commit, predicate func(clients.Commit) bool) bool {
for i := range commits {
if !predicate(commits[i]) {
return false
}
}
return true
}
// Copyright 2022 OpenSSF 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 raw
import (
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
)
// WebHook retrieves the raw data for the WebHooks check.
func WebHook(c *checker.CheckRequest) (checker.WebhooksData, error) {
hooksResp, err := c.RepoClient.ListWebhooks()
if err != nil {
return checker.WebhooksData{},
sce.WithMessage(sce.ErrScorecardInternal, "Client.Repositories.ListWebhooks")
}
return checker.WebhooksData{
Webhooks: hooksResp,
}, nil
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckSAST is the registered name for SAST.
const CheckSAST = "SAST"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.FileBased,
}
if err := registerCheck(CheckSAST, SAST, supportedRequestTypes); err != nil {
// This should never happen.
panic(err)
}
}
// SAST runs SAST check.
func SAST(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.SAST(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSAST, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.SASTResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.SAST)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSAST, e)
}
// Return the score evaluation.
ret := evaluation.SAST(CheckSAST, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2024 OpenSSF 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 checks
import (
"os"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// SBOM is the registered name for SBOM.
const CheckSBOM = "SBOM"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckSBOM, SBOM, nil); err != nil {
// this should never happen
panic(err)
}
}
// SBOM runs SBOM check.
func SBOM(c *checker.CheckRequest) checker.CheckResult {
_, enabled := os.LookupEnv("SCORECARD_EXPERIMENTAL")
if !enabled {
c.Dlogger.Warn(&checker.LogMessage{
Text: "SCORECARD_EXPERIMENTAL is not set, not running the SBOM check",
})
e := sce.WithMessage(sce.ErrUnsupportedCheck, "SCORECARD_EXPERIMENTAL is not set, not running the SBOM check")
return checker.CreateRuntimeErrorResult(CheckSBOM, e)
}
rawData, err := raw.SBOM(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSBOM, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.SBOMResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.SBOM)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSBOM, e)
}
ret := evaluation.SBOM(CheckSBOM, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckSecurityPolicy is the registered name for SecurityPolicy.
const CheckSecurityPolicy = "Security-Policy"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckSecurityPolicy, SecurityPolicy, supportedRequestTypes); err != nil {
// This should never happen.
panic(err)
}
}
// SecurityPolicy runs Security-Policy check.
func SecurityPolicy(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.SecurityPolicy(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSecurityPolicy, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.SecurityPolicyResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.SecurityPolicy)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSecurityPolicy, e)
}
// Return the score evaluation.
ret := evaluation.SecurityPolicy(CheckSecurityPolicy, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2020 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckSignedReleases is the registered name for SignedReleases.
const CheckSignedReleases = "Signed-Releases"
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckSignedReleases, SignedReleases, nil); err != nil {
// this should never happen
panic(err)
}
}
// SignedReleases runs Signed-Releases check.
func SignedReleases(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.SignedReleases(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckSignedReleases, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.SignedReleasesResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.SignedReleases)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckFuzzing, e)
}
// Return the score evaluation.
ret := evaluation.SignedReleases(CheckSignedReleases, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2022 OpenSSF 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 checks
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
// CheckVulnerabilities is the registered name for the OSV check.
const CheckVulnerabilities = "Vulnerabilities"
//nolint:gochecknoinits
func init() {
supportedRequestTypes := []checker.RequestType{
checker.CommitBased,
checker.FileBased,
}
if err := registerCheck(CheckVulnerabilities, Vulnerabilities, supportedRequestTypes); err != nil {
// this should never happen
panic(err)
}
}
// Vulnerabilities runs Vulnerabilities check.
func Vulnerabilities(c *checker.CheckRequest) checker.CheckResult {
rawData, err := raw.Vulnerabilities(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckVulnerabilities, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.VulnerabilitiesResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.Vulnerabilities)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckVulnerabilities, e)
}
ret := evaluation.Vulnerabilities(CheckVulnerabilities, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2022 OpenSSF 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 checks
import (
"os"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/evaluation"
"github.com/ossf/scorecard/v5/checks/raw"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/probes"
"github.com/ossf/scorecard/v5/probes/zrunner"
)
const (
// CheckWebHooks is the registered name for WebHooks.
CheckWebHooks = "Webhooks"
)
//nolint:gochecknoinits
func init() {
if err := registerCheck(CheckWebHooks, WebHooks, nil); err != nil {
// this should never happen
panic(err)
}
}
// WebHooks run Webhooks check.
func WebHooks(c *checker.CheckRequest) checker.CheckResult {
// TODO: remove this check when v6 is released
_, enabled := os.LookupEnv("SCORECARD_EXPERIMENTAL")
if !enabled {
c.Dlogger.Warn(&checker.LogMessage{
Text: "SCORECARD_EXPERIMENTAL is not set, not running the Webhook check",
})
e := sce.WithMessage(sce.ErrUnsupportedCheck, "SCORECARD_EXPERIMENTAL is not set, not running the Webhook check")
return checker.CreateRuntimeErrorResult(CheckWebHooks, e)
}
rawData, err := raw.WebHook(c)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckWebHooks, e)
}
// Set the raw results.
pRawResults := getRawResults(c)
pRawResults.WebhookResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.Webhook)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckWebHooks, e)
}
// Return the score evaluation.
ret := evaluation.Webhooks(CheckWebHooks, findings, c.Dlogger)
ret.Findings = findings
return ret
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"fmt"
"sync"
"time"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit"
)
type auditHandler struct {
auditClient audit.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
createdAt time.Time
queryLog fnQueryLog
}
func (a *auditHandler) init(ctx context.Context, repourl *Repo) {
a.ctx = ctx
a.errSetup = nil
a.once = new(sync.Once)
a.repourl = repourl
a.queryLog = a.auditClient.QueryLog
}
type (
fnQueryLog func(ctx context.Context, args audit.QueryLogArgs) (*audit.AuditLogQueryResult, error)
)
func (a *auditHandler) setup() error {
a.once.Do(func() {
continuationToken := ""
for {
auditLog, err := a.queryLog(a.ctx, audit.QueryLogArgs{
ContinuationToken: &continuationToken,
})
if err != nil {
a.errSetup = fmt.Errorf("error querying audit log: %w", err)
return
}
// Check if Git.CreateRepo event exists for the repository
for i := range *auditLog.DecoratedAuditLogEntries {
entry := &(*auditLog.DecoratedAuditLogEntries)[i]
if *entry.ActionId == "Git.CreateRepo" &&
*entry.ProjectName == a.repourl.project &&
(*entry.Data)["RepoName"] == a.repourl.name {
a.createdAt = entry.Timestamp.Time
return
}
}
if *auditLog.HasMore {
continuationToken = *auditLog.ContinuationToken
} else {
return
}
}
})
return a.errSetup
}
func (a *auditHandler) getRepsitoryCreatedAt() (time.Time, error) {
if err := a.setup(); err != nil {
return time.Time{}, fmt.Errorf("error during auditHandler.setup: %w", err)
}
return a.createdAt, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"fmt"
"sync"
"github.com/google/uuid"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/ossf/scorecard/v5/clients"
)
type branchesHandler struct {
gitClient git.Client
ctx context.Context
once *sync.Once
errSetup error
repourl *Repo
defaultBranchRef *clients.BranchRef
queryBranch fnQueryBranch
getPolicyConfigurations fnGetPolicyConfigurations
}
func (b *branchesHandler) init(ctx context.Context, repourl *Repo) {
b.ctx = ctx
b.repourl = repourl
b.errSetup = nil
b.once = new(sync.Once)
b.queryBranch = b.gitClient.GetBranch
b.getPolicyConfigurations = b.gitClient.GetPolicyConfigurations
}
type (
fnQueryBranch func(ctx context.Context, args git.GetBranchArgs) (*git.GitBranchStats, error)
fnGetPolicyConfigurations func(
ctx context.Context,
args git.GetPolicyConfigurationsArgs,
) (*git.GitPolicyConfigurationResponse, error)
)
func (b *branchesHandler) setup() error {
b.once.Do(func() {
args := git.GetBranchArgs{
RepositoryId: &b.repourl.id,
Name: &b.repourl.defaultBranch,
}
branch, err := b.queryBranch(b.ctx, args)
if err != nil {
b.errSetup = fmt.Errorf("request for default branch failed with error %w", err)
return
}
b.defaultBranchRef = &clients.BranchRef{
Name: branch.Name,
}
b.errSetup = nil
})
return b.errSetup
}
func (b *branchesHandler) getDefaultBranch() (*clients.BranchRef, error) {
if err := b.setup(); err != nil {
return nil, fmt.Errorf("error during branchesHandler.setup: %w", err)
}
return b.defaultBranchRef, nil
}
func (b *branchesHandler) getBranch(branchName string) (*clients.BranchRef, error) {
branch, err := b.queryBranch(b.ctx, git.GetBranchArgs{
RepositoryId: &b.repourl.id,
Name: &branchName,
})
if err != nil {
return nil, fmt.Errorf("request for branch %s failed with error %w", branchName, err)
}
refName := fmt.Sprintf("refs/heads/%s", *branch.Name)
repositoryID, err := uuid.Parse(b.repourl.id)
if err != nil {
return nil, fmt.Errorf("error parsing repository ID %s: %w", b.repourl.id, err)
}
args := git.GetPolicyConfigurationsArgs{
RepositoryId: &repositoryID,
RefName: &refName,
}
response, err := b.getPolicyConfigurations(b.ctx, args)
if err != nil {
return nil, fmt.Errorf("request for policy configurations failed with error %w", err)
}
isBranchProtected := false
if len(*response.PolicyConfigurations) > 0 {
isBranchProtected = true
}
// TODO: map Azure DevOps branch protection to Scorecard branch protection
return &clients.BranchRef{
Name: branch.Name,
Protected: &isBranchProtected,
}, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/build"
"github.com/ossf/scorecard/v5/clients"
)
type buildsHandler struct {
ctx context.Context
repourl *Repo
buildClient build.Client
getBuildDefinitions fnListBuildDefinitions
getBuilds fnGetBuilds
}
type (
fnListBuildDefinitions func(
ctx context.Context,
args build.GetDefinitionsArgs,
) (*build.GetDefinitionsResponseValue, error)
fnGetBuilds func(
ctx context.Context,
args build.GetBuildsArgs,
) (*build.GetBuildsResponseValue, error)
)
func (b *buildsHandler) init(ctx context.Context, repourl *Repo) {
b.ctx = ctx
b.repourl = repourl
b.getBuildDefinitions = b.buildClient.GetDefinitions
b.getBuilds = b.buildClient.GetBuilds
}
func (b *buildsHandler) listSuccessfulBuilds(filename string) ([]clients.WorkflowRun, error) {
buildDefinitions := make([]build.BuildDefinitionReference, 0)
includeAllProperties := true
repositoryType := "TfsGit"
continuationToken := ""
for {
args := build.GetDefinitionsArgs{
Project: &b.repourl.project,
RepositoryId: &b.repourl.id,
RepositoryType: &repositoryType,
IncludeAllProperties: &includeAllProperties,
YamlFilename: &filename,
ContinuationToken: &continuationToken,
}
response, err := b.getBuildDefinitions(b.ctx, args)
if err != nil {
return nil, err
}
buildDefinitions = append(buildDefinitions, response.Value...)
if response.ContinuationToken == "" {
break
}
continuationToken = response.ContinuationToken
}
buildIds := make([]int, 0, len(buildDefinitions))
for i := range buildDefinitions {
buildIds = append(buildIds, *buildDefinitions[i].Id)
}
args := build.GetBuildsArgs{
Project: &b.repourl.project,
Definitions: &buildIds,
ResultFilter: &build.BuildResultValues.Succeeded,
}
builds, err := b.getBuilds(b.ctx, args)
if err != nil {
return nil, err
}
workflowRuns := make([]clients.WorkflowRun, 0, len(builds.Value))
for i := range builds.Value {
currentBuild := builds.Value[i]
workflowRuns = append(workflowRuns, clients.WorkflowRun{
URL: *currentBuild.Url,
HeadSHA: currentBuild.SourceVersion,
})
}
return workflowRuns, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/audit"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/build"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/policy"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/projectanalysis"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/search"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/servicehooks"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
"github.com/ossf/scorecard/v5/clients"
)
var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type azuredevopsrepo.Repo")
errDefaultBranchNotFound = errors.New("default branch not found")
)
type Client struct {
azdoClient git.Client
ctx context.Context
repourl *Repo
repo *git.GitRepository
audit *auditHandler
branches *branchesHandler
builds *buildsHandler
commits *commitsHandler
contributors *contributorsHandler
languages *languagesHandler
policy *policyHandler
search *searchHandler
searchCommits *searchCommitsHandler
servicehooks *servicehooksHandler
workItems *workItemsHandler
zip *zipHandler
commitDepth int
}
func (c *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error {
azdoRepo, ok := inputRepo.(*Repo)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}
repo, err := c.azdoClient.GetRepository(c.ctx, git.GetRepositoryArgs{
Project: &azdoRepo.project,
RepositoryId: &azdoRepo.name,
})
if err != nil {
return fmt.Errorf("could not get repository with error: %w", err)
}
c.repo = repo
if commitDepth <= 0 {
c.commitDepth = 30 // default
} else {
c.commitDepth = commitDepth
}
branch := strings.TrimPrefix(*repo.DefaultBranch, "refs/heads/")
c.repourl = &Repo{
scheme: azdoRepo.scheme,
host: azdoRepo.host,
organization: azdoRepo.organization,
project: azdoRepo.project,
projectID: repo.Project.Id.String(),
name: azdoRepo.name,
id: repo.Id.String(),
defaultBranch: branch,
commitSHA: commitSHA,
}
c.audit.init(c.ctx, c.repourl)
c.branches.init(c.ctx, c.repourl)
c.builds.init(c.ctx, c.repourl)
c.commits.init(c.ctx, c.repourl, c.commitDepth)
c.contributors.init(c.ctx, c.repourl)
c.languages.init(c.ctx, c.repourl)
c.policy.init(c.ctx, c.repourl)
c.search.init(c.ctx, c.repourl)
c.searchCommits.init(c.ctx, c.repourl)
c.servicehooks.init(c.ctx, c.repourl)
c.workItems.init(c.ctx, c.repourl)
c.zip.init(c.ctx, c.repourl)
return nil
}
func (c *Client) URI() string {
return c.repourl.URI()
}
func (c *Client) IsArchived() (bool, error) {
return *c.repo.IsDisabled, nil
}
func (c *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
return c.zip.listFiles(predicate)
}
func (c *Client) LocalPath() (string, error) {
return c.zip.getLocalPath()
}
func (c *Client) GetFileReader(filename string) (io.ReadCloser, error) {
return c.zip.getFile(filename)
}
func (c *Client) GetBranch(branch string) (*clients.BranchRef, error) {
return c.branches.getBranch(branch)
}
func (c *Client) GetCreatedAt() (time.Time, error) {
createdAt, err := c.audit.getRepsitoryCreatedAt()
if err != nil {
return time.Time{}, err
}
// The audit log may not be enabled on the repository
if createdAt.IsZero() {
return c.commits.getFirstCommitCreatedAt()
}
return createdAt, nil
}
func (c *Client) GetDefaultBranchName() (string, error) {
if len(c.repourl.defaultBranch) > 0 {
return c.repourl.defaultBranch, nil
}
return "", errDefaultBranchNotFound
}
func (c *Client) GetDefaultBranch() (*clients.BranchRef, error) {
return c.branches.getDefaultBranch()
}
// Org repository, AKA the <org>/.github repository, is a GitHub-specific feature.
func (c *Client) GetOrgRepoClient(context.Context) (clients.RepoClient, error) {
return nil, clients.ErrUnsupportedFeature
}
func (c *Client) ListCommits() ([]clients.Commit, error) {
return c.commits.listCommits()
}
func (c *Client) ListIssues() ([]clients.Issue, error) {
return c.workItems.listIssues()
}
// Azure DevOps doesn't have a license detection feature.
// Thankfully, the License check falls back to file-based detection.
func (c *Client) ListLicenses() ([]clients.License, error) {
return nil, clients.ErrUnsupportedFeature
}
func (c *Client) ListReleases() ([]clients.Release, error) {
return nil, clients.ErrUnsupportedFeature
}
func (c *Client) ListContributors() ([]clients.User, error) {
return c.contributors.listContributors()
}
func (c *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return c.builds.listSuccessfulBuilds(filename)
}
func (c *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return c.policy.listCheckRunsForRef(ref)
}
func (c *Client) ListStatuses(ref string) ([]clients.Status, error) {
return c.commits.listStatuses(ref)
}
func (c *Client) ListWebhooks() ([]clients.Webhook, error) {
return c.servicehooks.listWebhooks()
}
func (c *Client) ListProgrammingLanguages() ([]clients.Language, error) {
return c.languages.listProgrammingLanguages()
}
func (c *Client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
return c.search.search(request)
}
func (c *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return c.searchCommits.searchCommits(request)
}
func (c *Client) Close() error {
return c.zip.cleanup()
}
func CreateAzureDevOpsClient(ctx context.Context, repo clients.Repo) (*Client, error) {
token := os.Getenv("AZURE_DEVOPS_AUTH_TOKEN")
return CreateAzureDevOpsClientWithToken(ctx, token, repo)
}
func CreateAzureDevOpsClientWithToken(ctx context.Context, token string, repo clients.Repo) (*Client, error) {
// https://dev.azure.com/<org>
url := "https://" + repo.Host() + "/" + strings.Split(repo.Path(), "/")[0]
connection := azuredevops.NewPatConnection(url, token)
client := azuredevops.NewClient(connection, url)
auditClient, err := audit.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops audit client with error: %w", err)
}
buildClient, err := build.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops build client with error: %w", err)
}
gitClient, err := git.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops git client with error: %w", err)
}
policyClient, err := policy.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops policy client with error: %w", err)
}
projectAnalysisClient, err := projectanalysis.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops project analysis client with error: %w", err)
}
searchClient, err := search.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops search client with error: %w", err)
}
servicehooksClient := servicehooks.NewClient(ctx, connection)
workItemsClient, err := workitemtracking.NewClient(ctx, connection)
if err != nil {
return nil, fmt.Errorf("could not create azure devops work item tracking client with error: %w", err)
}
return &Client{
ctx: ctx,
azdoClient: gitClient,
audit: &auditHandler{
auditClient: auditClient,
},
branches: &branchesHandler{
gitClient: gitClient,
},
builds: &buildsHandler{
buildClient: buildClient,
},
commits: &commitsHandler{
gitClient: gitClient,
},
contributors: &contributorsHandler{
gitClient: gitClient,
},
languages: &languagesHandler{
projectAnalysisClient: projectAnalysisClient,
},
policy: &policyHandler{
gitClient: gitClient,
policyClient: policyClient,
},
search: &searchHandler{
searchClient: searchClient,
},
searchCommits: &searchCommitsHandler{
gitClient: gitClient,
},
servicehooks: &servicehooksHandler{
servicehooksClient: servicehooksClient,
},
workItems: &workItemsHandler{
workItemsClient: workItemsClient,
},
zip: &zipHandler{
client: client,
},
}, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"errors"
"fmt"
"regexp"
"sync"
"time"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/ossf/scorecard/v5/clients"
)
var (
errMultiplePullRequests = errors.New("expected 1 pull request for commit, got multiple")
errRefNotFound = errors.New("ref not found")
)
type commitsHandler struct {
gitClient git.Client
ctx context.Context
errSetup error
once *sync.Once
repourl *Repo
commitsRaw *[]git.GitCommitRef
pullRequestsRaw *git.GitPullRequestQuery
firstCommitCreatedAt time.Time
getCommits fnGetCommits
getPullRequestQuery fnGetPullRequestQuery
getFirstCommit fnGetFirstCommit
getRefs fnGetRefs
getStatuses fnGetStatuses
commitDepth int
}
func (c *commitsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) {
c.ctx = ctx
c.repourl = repourl
c.errSetup = nil
c.once = new(sync.Once)
c.commitDepth = commitDepth
c.getCommits = c.gitClient.GetCommits
c.getPullRequestQuery = c.gitClient.GetPullRequestQuery
c.getFirstCommit = c.gitClient.GetCommits
c.getRefs = c.gitClient.GetRefs
c.getStatuses = c.gitClient.GetStatuses
}
type (
fnGetCommits func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error)
fnGetPullRequestQuery func(ctx context.Context, args git.GetPullRequestQueryArgs) (*git.GitPullRequestQuery, error)
fnGetFirstCommit func(ctx context.Context, args git.GetCommitsArgs) (*[]git.GitCommitRef, error)
fnGetRefs func(ctx context.Context, args git.GetRefsArgs) (*git.GetRefsResponseValue, error)
fnGetStatuses func(ctx context.Context, args git.GetStatusesArgs) (*[]git.GitStatus, error)
)
func (c *commitsHandler) setup() error {
c.once.Do(func() {
var itemVersion git.GitVersionDescriptor
if c.repourl.commitSHA == headCommit {
itemVersion = git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Branch,
Version: &c.repourl.defaultBranch,
}
} else {
itemVersion = git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Commit,
Version: &c.repourl.commitSHA,
}
}
opt := git.GetCommitsArgs{
RepositoryId: &c.repourl.id,
Top: &c.commitDepth,
SearchCriteria: &git.GitQueryCommitsCriteria{
ItemVersion: &itemVersion,
},
}
commits, err := c.getCommits(c.ctx, opt)
if err != nil {
c.errSetup = fmt.Errorf("request for commits failed with %w", err)
return
}
commitIds := make([]string, len(*commits))
for i := range *commits {
commitIds[i] = *(*commits)[i].CommitId
}
pullRequestQuery := git.GetPullRequestQueryArgs{
RepositoryId: &c.repourl.id,
Queries: &git.GitPullRequestQuery{
Queries: &[]git.GitPullRequestQueryInput{
{
Type: &git.GitPullRequestQueryTypeValues.LastMergeCommit,
Items: &commitIds,
},
},
},
}
pullRequests, err := c.getPullRequestQuery(c.ctx, pullRequestQuery)
if err != nil {
c.errSetup = fmt.Errorf("request for pull requests failed with %w", err)
return
}
switch {
case len(*commits) == 0:
c.firstCommitCreatedAt = time.Time{}
case len(*commits) < c.commitDepth:
c.firstCommitCreatedAt = (*commits)[len(*commits)-1].Committer.Date.Time
default:
firstCommit, err := c.getFirstCommit(c.ctx, git.GetCommitsArgs{
RepositoryId: &c.repourl.id,
SearchCriteria: &git.GitQueryCommitsCriteria{
Top: &[]int{1}[0],
ShowOldestCommitsFirst: &[]bool{true}[0],
ItemVersion: &git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Branch,
Version: &c.repourl.defaultBranch,
},
},
})
if err != nil {
c.errSetup = fmt.Errorf("request for first commit failed with %w", err)
return
}
c.firstCommitCreatedAt = (*firstCommit)[0].Committer.Date.Time
}
c.commitsRaw = commits
c.pullRequestsRaw = pullRequests
c.errSetup = nil
})
return c.errSetup
}
func (c *commitsHandler) listCommits() ([]clients.Commit, error) {
err := c.setup()
if err != nil {
return nil, fmt.Errorf("error during commitsHandler.setup: %w", err)
}
commits := make([]clients.Commit, len(*c.commitsRaw))
for i := range *c.commitsRaw {
commit := &(*c.commitsRaw)[i]
commits[i] = clients.Commit{
SHA: *commit.CommitId,
Message: *commit.Comment,
CommittedDate: commit.Committer.Date.Time,
Committer: clients.User{
Login: *commit.Committer.Email,
},
}
}
// Associate pull requests with commits
pullRequests, err := c.listPullRequests()
if err != nil {
return nil, fmt.Errorf("error during commitsHandler.listPullRequests: %w", err)
}
for i := range commits {
commit := &commits[i]
associatedPullRequest, ok := pullRequests[commit.SHA]
if !ok {
continue
}
commit.AssociatedMergeRequest = associatedPullRequest
}
return commits, nil
}
func (c *commitsHandler) listPullRequests() (map[string]clients.PullRequest, error) {
err := c.setup()
if err != nil {
return nil, fmt.Errorf("error during commitsHandler.setup: %w", err)
}
pullRequests := make(map[string]clients.PullRequest)
for commit, azdoPullRequests := range (*c.pullRequestsRaw.Results)[0] {
if len(azdoPullRequests) == 0 {
continue
}
if len(azdoPullRequests) > 1 {
return nil, errMultiplePullRequests
}
azdoPullRequest := azdoPullRequests[0]
pullRequests[commit] = clients.PullRequest{
Number: *azdoPullRequest.PullRequestId,
Author: clients.User{
Login: *azdoPullRequest.CreatedBy.DisplayName,
},
HeadSHA: *azdoPullRequest.LastMergeCommit.CommitId,
MergedAt: azdoPullRequest.ClosedDate.Time,
}
}
return pullRequests, nil
}
func (c *commitsHandler) getFirstCommitCreatedAt() (time.Time, error) {
if err := c.setup(); err != nil {
return time.Time{}, fmt.Errorf("error during commitsHandler.setup: %w", err)
}
return c.firstCommitCreatedAt, nil
}
func (c *commitsHandler) listStatuses(ref string) ([]clients.Status, error) {
matched, err := regexp.MatchString("^[0-9a-fA-F]{40}$", ref)
if err != nil {
return nil, fmt.Errorf("error matching ref: %w", err)
}
if matched {
return c.statusFromCommit(ref)
} else {
return c.statusFromHead(ref)
}
}
func (c *commitsHandler) statusFromHead(ref string) ([]clients.Status, error) {
includeStatuses := true
filter := fmt.Sprintf("heads/%s", ref)
args := git.GetRefsArgs{
RepositoryId: &c.repourl.id,
Filter: &filter,
IncludeStatuses: &includeStatuses,
LatestStatusesOnly: &[]bool{true}[0],
}
response, err := c.getRefs(c.ctx, args)
if err != nil {
return nil, fmt.Errorf("error getting refs: %w", err)
}
if len(response.Value) != 1 {
return nil, errRefNotFound
}
statuses := response.Value[0].Statuses
if statuses == nil {
return []clients.Status{}, nil
}
result := make([]clients.Status, len(*statuses))
for i, status := range *statuses {
result[i] = clients.Status{
State: convertAzureDevOpsStatus(&status),
Context: *status.Context.Name,
URL: *status.TargetUrl,
TargetURL: *status.TargetUrl,
}
}
return result, nil
}
func (c *commitsHandler) statusFromCommit(ref string) ([]clients.Status, error) {
args := git.GetStatusesArgs{
RepositoryId: &c.repourl.id,
CommitId: &ref,
LatestOnly: &[]bool{true}[0],
}
response, err := c.getStatuses(c.ctx, args)
if err != nil {
return nil, fmt.Errorf("error getting statuses: %w", err)
}
result := make([]clients.Status, len(*response))
for i, status := range *response {
result[i] = clients.Status{
Context: *status.Context.Name,
State: convertAzureDevOpsStatus(&status),
URL: *status.TargetUrl,
TargetURL: *status.TargetUrl,
}
}
return result, nil
}
func convertAzureDevOpsStatus(s *git.GitStatus) string {
switch *s.State {
case "succeeded":
return "success"
case "failed", "error":
return "failure"
case "notApplicable", "notSet", "pending":
return "neutral"
default:
return string(*s.State)
}
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"sync"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/ossf/scorecard/v5/clients"
)
type contributorsHandler struct {
ctx context.Context
once *sync.Once
repourl *Repo
gitClient git.Client
errSetup error
getCommits fnGetCommits
contributors []clients.User
}
func (c *contributorsHandler) init(ctx context.Context, repourl *Repo) {
c.ctx = ctx
c.once = new(sync.Once)
c.repourl = repourl
c.errSetup = nil
c.getCommits = c.gitClient.GetCommits
c.contributors = nil
}
func (c *contributorsHandler) setup() error {
c.once.Do(func() {
contributors := make(map[string]clients.User)
commitsPageSize := 1000
skip := 0
for {
args := git.GetCommitsArgs{
RepositoryId: &c.repourl.id,
SearchCriteria: &git.GitQueryCommitsCriteria{
Top: &commitsPageSize,
Skip: &skip,
},
}
commits, err := c.getCommits(c.ctx, args)
if err != nil {
c.errSetup = err
return
}
if commits == nil || len(*commits) == 0 {
break
}
for i := range *commits {
commit := (*commits)[i]
login := commit.Author.Email
if login == nil {
login = commit.Author.Name
}
if _, ok := contributors[*login]; ok {
user := contributors[*login]
user.NumContributions++
contributors[*login] = user
} else {
contributors[*login] = clients.User{
Login: *login,
NumContributions: 1,
Companies: []string{c.repourl.organization},
}
}
}
skip += commitsPageSize
}
for _, contributor := range contributors {
c.contributors = append(c.contributors, contributor)
}
})
return c.errSetup
}
func (c *contributorsHandler) listContributors() ([]clients.User, error) {
if err := c.setup(); err != nil {
return nil, err
}
return c.contributors, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"log"
"sync"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/projectanalysis"
"github.com/ossf/scorecard/v5/clients"
)
type languagesHandler struct {
ctx context.Context
once *sync.Once
repourl *Repo
projectAnalysisClient projectanalysis.Client
projectAnalysis fnGetProjectLanguageAnalytics
errSetup error
languages []clients.Language
}
func (l *languagesHandler) init(ctx context.Context, repourl *Repo) {
l.ctx = ctx
l.once = new(sync.Once)
l.repourl = repourl
l.languages = []clients.Language{}
l.projectAnalysis = l.projectAnalysisClient.GetProjectLanguageAnalytics
l.errSetup = nil
}
type (
fnGetProjectLanguageAnalytics func(
ctx context.Context,
args projectanalysis.GetProjectLanguageAnalyticsArgs,
) (*projectanalysis.ProjectLanguageAnalytics, error)
)
func (l *languagesHandler) setup() error {
l.once.Do(func() {
args := projectanalysis.GetProjectLanguageAnalyticsArgs{
Project: &l.repourl.project,
}
res, err := l.projectAnalysis(l.ctx, args)
if err != nil {
l.errSetup = err
return
}
if res.ResultPhase != &projectanalysis.ResultPhaseValues.Full {
log.Println("Project language analytics not ready yet. Results may be incomplete.")
}
for _, repo := range *res.RepositoryLanguageAnalytics {
if repo.Id.String() != l.repourl.id {
continue
}
// TODO: Find the number of lines in the repo and multiply the value of each language by that number.
for _, language := range *repo.LanguageBreakdown {
percentage := 0
if language.LanguagePercentage != nil {
percentage = int(*language.LanguagePercentage)
}
l.languages = append(l.languages,
clients.Language{
Name: clients.LanguageName(*language.Name),
NumLines: percentage,
},
)
}
}
})
return l.errSetup
}
func (l *languagesHandler) listProgrammingLanguages() ([]clients.Language, error) {
if err := l.setup(); err != nil {
return nil, err
}
return l.languages, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"errors"
"fmt"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/policy"
"github.com/ossf/scorecard/v5/clients"
)
var errPullRequestNotFound = errors.New("pull request not found")
type policyHandler struct {
ctx context.Context
repourl *Repo
gitClient git.Client
policyClient policy.Client
getPullRequestQuery fnGetPullRequestQuery
getPolicyEvaluations fnGetPolicyEvaluations
}
type fnGetPolicyEvaluations func(
ctx context.Context,
args policy.GetPolicyEvaluationsArgs,
) (*[]policy.PolicyEvaluationRecord, error)
func (p *policyHandler) init(ctx context.Context, repourl *Repo) {
p.ctx = ctx
p.repourl = repourl
p.getPullRequestQuery = p.gitClient.GetPullRequestQuery
p.getPolicyEvaluations = p.policyClient.GetPolicyEvaluations
}
func (p *policyHandler) listCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
// The equivalent of a check run in Azure DevOps is a policy evaluation.
// Unfortunately, Azure DevOps does not provide a way to list policy evaluations for a specific ref.
// Instead, they are associated with a pull request.
// Get the pull request associated with the ref.
args := git.GetPullRequestQueryArgs{
RepositoryId: &p.repourl.id,
Queries: &git.GitPullRequestQuery{
Queries: &[]git.GitPullRequestQueryInput{
{
Type: &git.GitPullRequestQueryTypeValues.LastMergeCommit,
Items: &[]string{ref},
},
},
},
}
queryPullRequests, err := p.getPullRequestQuery(p.ctx, args)
if err != nil {
return nil, err
}
if len(*queryPullRequests.Results) != 1 {
return nil, errMultiplePullRequests
}
result := (*queryPullRequests.Results)[0]
pullRequests, ok := result[ref]
if !ok {
return nil, errPullRequestNotFound
}
if len(pullRequests) != 1 {
return nil, errMultiplePullRequests
}
pullRequest := pullRequests[0]
artifactID := fmt.Sprintf("vstfs:///CodeReview/CodeReviewId/%s/%d", p.repourl.projectID, *pullRequest.PullRequestId)
argsPolicy := policy.GetPolicyEvaluationsArgs{
Project: &p.repourl.project,
ArtifactId: &artifactID,
}
policyEvaluations, err := p.getPolicyEvaluations(p.ctx, argsPolicy)
if err != nil {
return nil, err
}
const completed = "completed"
checkRuns := make([]clients.CheckRun, len(*policyEvaluations))
for i, evaluation := range *policyEvaluations {
checkrun := clients.CheckRun{}
switch *evaluation.Status {
case policy.PolicyEvaluationStatusValues.Queued:
checkrun.Status = "queued"
case policy.PolicyEvaluationStatusValues.Running:
checkrun.Status = "in_progress"
case policy.PolicyEvaluationStatusValues.Approved:
checkrun.Status = completed
checkrun.Conclusion = "success"
case policy.PolicyEvaluationStatusValues.Rejected, policy.PolicyEvaluationStatusValues.Broken:
checkrun.Status = completed
checkrun.Conclusion = "failure"
case policy.PolicyEvaluationStatusValues.NotApplicable:
checkrun.Status = completed
checkrun.Conclusion = "neutral"
default:
checkrun.Status = string(*evaluation.Status)
}
checkRuns[i] = checkrun
}
return checkRuns, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"fmt"
"net/url"
"strings"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type Repo struct {
scheme string
host string
organization string
project string
projectID string
name string
id string
defaultBranch string
commitSHA string
metadata []string
}
// Parses input string into repoURL struct
/*
Accepted input string formats are as follows:
- "dev.azure.com/<organization:string>/<project:string>/_git/<repository:string>"
- "https://dev.azure.com/<organization:string>/<project:string>/_git/<repository:string>"
*/
func (r *Repo) parse(input string) error {
u, err := url.Parse(withDefaultScheme(input))
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}
const splitLen = 4
split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen)
if len(split) != splitLen {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Azure DevOps repo format is invalid: %s", input))
}
r.scheme, r.host, r.organization, r.project, r.name = u.Scheme, u.Host, split[0], split[1], split[3]
return nil
}
// Allow skipping scheme for ease-of-use, default to https.
func withDefaultScheme(uri string) string {
if strings.Contains(uri, "://") {
return uri
}
return "https://" + uri
}
// URI implements Repo.URI().
func (r *Repo) URI() string {
return fmt.Sprintf("%s/%s/%s/%s/%s", r.host, r.organization, r.project, "_git", r.name)
}
func (r *Repo) Host() string {
return r.host
}
// String implements Repo.String.
func (r *Repo) String() string {
return fmt.Sprintf("%s-%s_%s_%s", r.host, r.organization, r.project, r.name)
}
// IsValid checks if the repoURL is valid.
func (r *Repo) IsValid() error {
if strings.TrimSpace(r.organization) == "" ||
strings.TrimSpace(r.project) == "" ||
strings.TrimSpace(r.name) == "" {
return sce.WithMessage(sce.ErrInvalidURL, "expected full project url: "+r.URI())
}
return nil
}
func (r *Repo) AppendMetadata(metadata ...string) {
r.metadata = append(r.metadata, metadata...)
}
// Metadata implements Repo.Metadata.
func (r *Repo) Metadata() []string {
return r.metadata
}
// Path() implements RepoClient.Path.
func (r *Repo) Path() string {
return fmt.Sprintf("%s/%s/%s/%s", r.organization, r.project, "_git", r.name)
}
// MakeAzureDevOpsRepo takes input of forms in parse and returns and implementation
// of clients.Repo interface.
func MakeAzureDevOpsRepo(input string) (clients.Repo, error) {
var repo Repo
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 in IsValid: %w", err)
}
return &repo, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/search"
"github.com/ossf/scorecard/v5/clients"
)
var errEmptyQuery = errors.New("search query is empty")
type searchHandler struct {
searchClient search.Client
once *sync.Once
ctx context.Context
repourl *Repo
searchCode fnSearchCode
}
func (s *searchHandler) init(ctx context.Context, repourl *Repo) {
s.ctx = ctx
s.once = new(sync.Once)
s.repourl = repourl
s.searchCode = s.searchClient.FetchCodeSearchResults
}
type (
fnSearchCode func(ctx context.Context, args search.FetchCodeSearchResultsArgs) (*search.CodeSearchResponse, error)
)
func (s *searchHandler) search(request clients.SearchRequest) (clients.SearchResponse, error) {
filters, query, err := s.buildFilters(request)
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("handler.buildQuery: %w", err)
}
searchResultsPageSize := 1000
args := search.FetchCodeSearchResultsArgs{
Request: &search.CodeSearchRequest{
Filters: &filters,
SearchText: &query,
Top: &searchResultsPageSize,
},
}
searchResults, err := s.searchCode(s.ctx, args)
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("FetchCodeSearchResults: %w", err)
}
return searchResponseFrom(searchResults), nil
}
func (s *searchHandler) buildFilters(request clients.SearchRequest) (map[string][]string, string, error) {
filters := make(map[string][]string)
query := strings.Builder{}
if request.Query == "" {
return filters, query.String(), errEmptyQuery
}
query.WriteString(request.Query)
query.WriteString(" ")
filters["Project"] = []string{s.repourl.project}
filters["Repository"] = []string{s.repourl.name}
if request.Path != "" {
filters["Path"] = []string{request.Path}
}
if request.Filename != "" {
query.WriteString(fmt.Sprintf("file:%s", request.Filename))
}
return filters, query.String(), nil
}
func searchResponseFrom(searchResults *search.CodeSearchResponse) clients.SearchResponse {
var results []clients.SearchResult
for _, result := range *searchResults.Results {
results = append(results, clients.SearchResult{
Path: *result.Path,
})
}
return clients.SearchResponse{
Results: results,
Hits: *searchResults.Count,
}
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"errors"
"fmt"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/git"
"github.com/ossf/scorecard/v5/clients"
)
var errMoreThanOnePullRequest = errors.New("more than one pull request found for a commit")
type searchCommitsHandler struct {
ctx context.Context
repourl *Repo
gitClient git.Client
getCommits fnGetCommits
getPullRequestQuery fnGetPullRequestQuery
}
func (s *searchCommitsHandler) init(ctx context.Context, repourl *Repo) {
s.ctx = ctx
s.repourl = repourl
s.getCommits = s.gitClient.GetCommits
s.getPullRequestQuery = s.gitClient.GetPullRequestQuery
}
func (s *searchCommitsHandler) searchCommits(searchOptions clients.SearchCommitsOptions) ([]clients.Commit, error) {
commits := make([]clients.Commit, 0)
commitsPageSize := 1000
skip := 0
var itemVersion git.GitVersionDescriptor
if s.repourl.commitSHA == headCommit {
itemVersion = git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Branch,
Version: &s.repourl.defaultBranch,
}
} else {
itemVersion = git.GitVersionDescriptor{
VersionType: &git.GitVersionTypeValues.Commit,
Version: &s.repourl.commitSHA,
}
}
for {
args := git.GetCommitsArgs{
RepositoryId: &s.repourl.id,
SearchCriteria: &git.GitQueryCommitsCriteria{
ItemVersion: &itemVersion,
Author: &searchOptions.Author,
Top: &commitsPageSize,
Skip: &skip,
},
}
response, err := s.getCommits(s.ctx, args)
if err != nil {
return nil, fmt.Errorf("failed to get commits: %w", err)
}
if response == nil || len(*response) == 0 {
break
}
for i := range *response {
commit := &(*response)[i]
pullRequest, err := s.getAssociatedPullRequest(commit)
if err != nil {
return nil, fmt.Errorf("failed to get associated pull request: %w", err)
}
commits = append(commits, clients.Commit{
SHA: *commit.CommitId,
Message: *commit.Comment,
CommittedDate: commit.Committer.Date.Time,
Committer: clients.User{
Login: *commit.Committer.Email,
},
AssociatedMergeRequest: pullRequest,
})
}
if len(*response) < commitsPageSize {
break
}
skip += commitsPageSize
}
return commits, nil
}
func (s *searchCommitsHandler) getAssociatedPullRequest(commit *git.GitCommitRef) (clients.PullRequest, error) {
query, err := s.getPullRequestQuery(s.ctx, git.GetPullRequestQueryArgs{
RepositoryId: &s.repourl.id,
Queries: &git.GitPullRequestQuery{
Queries: &[]git.GitPullRequestQueryInput{
{
Items: &[]string{*commit.CommitId},
Type: &git.GitPullRequestQueryTypeValues.Commit,
},
},
},
})
if err != nil {
return clients.PullRequest{}, err
}
if query == nil || query.Results == nil {
return clients.PullRequest{}, nil
}
results := *query.Results
if len(results) == 0 {
return clients.PullRequest{}, nil
}
if len(results) > 1 {
return clients.PullRequest{}, errMoreThanOnePullRequest
}
// TODO: Azure DevOps API returns a list of pull requests for a commit.
// Scorecard currently only supports one pull request per commit.
result := results[0]
pullRequests, ok := result[*commit.CommitId]
if !ok || len(pullRequests) == 0 {
return clients.PullRequest{}, nil
}
return clients.PullRequest{
Number: *pullRequests[0].PullRequestId,
}, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"sync"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/servicehooks"
"github.com/ossf/scorecard/v5/clients"
)
var webHooksConsumerID = "webHooks"
type servicehooksHandler struct {
ctx context.Context
once *sync.Once
repourl *Repo
servicehooksClient servicehooks.Client
listSubscriptions fnListSubscriptions
errSetup error
webhooks []clients.Webhook
}
type fnListSubscriptions func(
ctx context.Context,
args servicehooks.ListSubscriptionsArgs,
) (*[]servicehooks.Subscription, error)
func (s *servicehooksHandler) init(ctx context.Context, repourl *Repo) {
s.ctx = ctx
s.once = new(sync.Once)
s.repourl = repourl
s.errSetup = nil
s.webhooks = nil
s.listSubscriptions = s.servicehooksClient.ListSubscriptions
}
func (s *servicehooksHandler) setup() error {
s.once.Do(func() {
args := servicehooks.ListSubscriptionsArgs{
ConsumerId: &webHooksConsumerID,
}
subscriptions, err := s.listSubscriptions(s.ctx, args)
if err != nil {
s.errSetup = err
return
}
for i := range *subscriptions {
subscription := (*subscriptions)[i]
usesAuthSecret := false
if subscription.ConsumerInputs != nil {
_, usesAuthSecret = (*subscription.ConsumerInputs)["basicAuthPassword"]
}
s.webhooks = append(s.webhooks, clients.Webhook{
// Azure DevOps uses uuid.UUID for ID, but Scorecard expects int64
// ID: *subscription.Id,
Path: (*subscription.ConsumerInputs)["url"],
UsesAuthSecret: usesAuthSecret,
})
}
})
return s.errSetup
}
func (s *servicehooksHandler) listWebhooks() ([]clients.Webhook, error) {
if err := s.setup(); err != nil {
return nil, err
}
return s.webhooks, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"context"
"fmt"
"sync"
"time"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
"github.com/ossf/scorecard/v5/clients"
)
var (
errSystemCreatedByFieldNotMap = fmt.Errorf("error: System.CreatedBy field is not a map")
errSystemCreatedByFieldNotUniqueName = fmt.Errorf("error: System.CreatedBy field does not contain a UniqueName")
errSystemCreatedDateFieldNotString = fmt.Errorf("error: System.CreatedDate field is not a string")
)
type (
fnQueryWorkItems func(
ctx context.Context,
args workitemtracking.QueryByWiqlArgs,
) (*workitemtracking.WorkItemQueryResult, error)
fnGetWorkItems func(
ctx context.Context,
args workitemtracking.GetWorkItemsArgs,
) (*[]workitemtracking.WorkItem, error)
fnGetWorkItemComments func(
ctx context.Context,
args workitemtracking.GetCommentsArgs,
) (*workitemtracking.CommentList, error)
)
type workItemsHandler struct {
ctx context.Context
repourl *Repo
once *sync.Once
errSetup error
workItemsClient workitemtracking.Client
queryWorkItems fnQueryWorkItems
getWorkItems fnGetWorkItems
getWorkItemComments fnGetWorkItemComments
issues []clients.Issue
}
func (w *workItemsHandler) init(ctx context.Context, repourl *Repo) {
w.ctx = ctx
w.errSetup = nil
w.once = new(sync.Once)
w.repourl = repourl
w.queryWorkItems = w.workItemsClient.QueryByWiql
w.getWorkItems = w.workItemsClient.GetWorkItems
w.getWorkItemComments = w.workItemsClient.GetComments
w.issues = nil
}
func (w *workItemsHandler) setup() error {
w.once.Do(func() {
wiql := `
SELECT [System.Id]
FROM WorkItems
WHERE [System.TeamProject] = @project
ORDER BY [System.Id] DESC
`
workItems, err := w.queryWorkItems(w.ctx, workitemtracking.QueryByWiqlArgs{
Project: &w.repourl.project,
Wiql: &workitemtracking.Wiql{
Query: &wiql,
},
})
if err != nil {
w.errSetup = fmt.Errorf("error getting work items: %w", err)
return
}
ids := make([]int, 0, len(*workItems.WorkItems))
for _, wi := range *workItems.WorkItems {
ids = append(ids, *wi.Id)
}
// Get details for each work item
workItemDetails, err := w.getWorkItems(w.ctx, workitemtracking.GetWorkItemsArgs{
Ids: &ids,
})
if err != nil {
w.errSetup = fmt.Errorf("error getting work item details: %w", err)
return
}
w.issues = make([]clients.Issue, 0, len(*workItemDetails))
for i := range *workItemDetails {
wi := &(*workItemDetails)[i]
createdBy, ok := (*wi.Fields)["System.CreatedBy"].(map[string]interface{})
if !ok {
w.errSetup = errSystemCreatedByFieldNotMap
return
}
uniqueName, ok := createdBy["uniqueName"].(string)
if !ok {
w.errSetup = errSystemCreatedByFieldNotUniqueName
return
}
createdDate, ok := (*wi.Fields)["System.CreatedDate"].(string)
if !ok {
w.errSetup = errSystemCreatedDateFieldNotString
return
}
parsedTime, err := time.Parse(time.RFC3339, createdDate)
if err != nil {
w.errSetup = fmt.Errorf("error parsing created date: %w", err)
return
}
// There is not currently an official API to get user permissions in Azure DevOps
// so we will default to RepoAssociationMember for all users.
repoAssociation := clients.RepoAssociationMember
issue := clients.Issue{
URI: wi.Url,
CreatedAt: &parsedTime,
Author: &clients.User{Login: uniqueName},
AuthorAssociation: &repoAssociation,
Comments: make([]clients.IssueComment, 0),
}
workItemComments, err := w.getWorkItemComments(w.ctx, workitemtracking.GetCommentsArgs{
Project: &w.repourl.project,
WorkItemId: wi.Id,
})
if err != nil {
w.errSetup = fmt.Errorf("error getting comments for work item %d: %w", *wi.Id, err)
return
}
for i := range *workItemComments.Comments {
workItemComment := &(*workItemComments.Comments)[i]
// There is not currently an official API to get user permissions in Azure DevOps
// so we will default to RepoAssociationMember for all users.
repoAssociation := clients.RepoAssociationMember
comment := clients.IssueComment{
CreatedAt: &workItemComment.CreatedDate.Time,
Author: &clients.User{Login: *workItemComment.CreatedBy.UniqueName},
AuthorAssociation: &repoAssociation,
}
issue.Comments = append(issue.Comments, comment)
}
w.issues = append(w.issues, issue)
}
})
return w.errSetup
}
func (w *workItemsHandler) listIssues() ([]clients.Issue, error) {
if err := w.setup(); err != nil {
return nil, fmt.Errorf("error during issuesHandler.setup: %w", err)
}
return w.issues, nil
}
// Copyright 2024 OpenSSF 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 azuredevopsrepo
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
sce "github.com/ossf/scorecard/v5/errors"
)
const (
repoDir = "repo*"
repoFilename = "azuredevopsrepo*.zip"
maxSize = 100 * 1024 * 1024 // 100MB limit
)
var (
errUnexpectedStatusCode = errors.New("unexpected status code")
errZipNotFound = errors.New("zip not found")
errInvalidFilePath = errors.New("invalid zip file: contains file path outside of target directory")
errFileTooLarge = errors.New("file too large, possible zip bomb")
)
type zipHandler struct {
client *azuredevops.Client
errSetup error
once *sync.Once
ctx context.Context
repourl *Repo
tempDir string
tempZipFile string
files []string
}
func (z *zipHandler) init(ctx context.Context, repourl *Repo) {
z.errSetup = nil
z.once = new(sync.Once)
z.ctx = ctx
z.repourl = repourl
}
func (z *zipHandler) setup() error {
z.once.Do(func() {
if err := z.cleanup(); err != nil {
z.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
if err := z.getZipfile(); err != nil {
z.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
if err := z.extractZip(); err != nil {
z.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
})
return z.errSetup
}
func (z *zipHandler) getZipfile() error {
tempDir, err := os.MkdirTemp("", repoDir)
if err != nil {
return fmt.Errorf("os.MkdirTemp: %w", err)
}
repoFile, err := os.CreateTemp(tempDir, repoFilename)
if err != nil {
return fmt.Errorf("%w io.Copy: %w", errZipNotFound, err)
}
defer repoFile.Close()
// The zip download API is not exposed in the Azure DevOps Go SDK, so we need to construct the request manually.
baseURL := fmt.Sprintf(
"https://%s/%s/%s/_apis/git/repositories/%s/items",
z.repourl.host,
z.repourl.organization,
z.repourl.project,
z.repourl.id)
queryParams := url.Values{}
queryParams.Add("path", "/")
queryParams.Add("download", "true")
queryParams.Add("api-version", "7.1-preview.1")
queryParams.Add("resolveLfs", "true")
queryParams.Add("$format", "zip")
if z.repourl.commitSHA == headCommit {
queryParams.Add("versionDescriptor.versionType", "branch")
queryParams.Add("versionDescriptor.version", z.repourl.defaultBranch)
} else {
queryParams.Add("versionDescriptor.versionType", "commit")
queryParams.Add("versionDescriptor.version", z.repourl.commitSHA)
}
parsedURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
req, err := z.client.CreateRequestMessage(
z.ctx,
http.MethodGet,
parsedURL,
"7.1",
nil,
"",
"application/zip",
map[string]string{},
)
if err != nil {
return fmt.Errorf("client.CreateRequestMessage: %w", err)
}
res, err := z.client.SendRequest(req)
if err != nil {
return fmt.Errorf("client.SendRequest: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: status code %d", errUnexpectedStatusCode, res.StatusCode)
}
if _, err := io.Copy(repoFile, res.Body); err != nil {
return fmt.Errorf("io.Copy: %w", err)
}
z.tempDir = tempDir
z.tempZipFile = repoFile.Name()
return nil
}
func (z *zipHandler) getLocalPath() (string, error) {
if err := z.setup(); err != nil {
return "", fmt.Errorf("error during zipHandler.setup: %w", err)
}
absTempDir, err := filepath.Abs(z.tempDir)
if err != nil {
return "", fmt.Errorf("error during filepath.Abs: %w", err)
}
return absTempDir, nil
}
func (z *zipHandler) extractZip() error {
zipReader, err := zip.OpenReader(z.tempZipFile)
if err != nil {
return fmt.Errorf("zip.OpenReader: %w", err)
}
defer zipReader.Close()
destinationPrefix := filepath.Clean(z.tempDir) + string(os.PathSeparator)
z.files = make([]string, 0, len(zipReader.File))
for _, file := range zipReader.File {
//nolint:gosec // G305: Handling of file paths is done below
filenamepath := filepath.Join(z.tempDir, file.Name)
if !strings.HasPrefix(filepath.Clean(filenamepath), destinationPrefix) {
return errInvalidFilePath
}
if err := os.MkdirAll(filepath.Dir(filenamepath), 0o755); err != nil {
return fmt.Errorf("error during os.MkdirAll: %w", err)
}
if file.FileInfo().IsDir() {
continue
}
outFile, err := os.OpenFile(filenamepath, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("os.OpenFile: %w", err)
}
rc, err := file.Open()
if err != nil {
return fmt.Errorf("file.Open: %w", err)
}
written, err := io.CopyN(outFile, rc, maxSize)
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("%w io.Copy: %w", errZipNotFound, err)
}
if written > maxSize {
return errFileTooLarge
}
outFile.Close()
filename := strings.TrimPrefix(filenamepath, destinationPrefix)
z.files = append(z.files, filename)
}
return nil
}
func (z *zipHandler) listFiles(predicate func(string) (bool, error)) ([]string, error) {
if err := z.setup(); err != nil {
return nil, fmt.Errorf("error during zipHandler.setup: %w", err)
}
ret := make([]string, 0)
for _, file := range z.files {
matches, err := predicate(file)
if err != nil {
return nil, err
}
if matches {
ret = append(ret, file)
}
}
return ret, nil
}
func (z *zipHandler) getFile(filename string) (*os.File, error) {
if err := z.setup(); err != nil {
return nil, fmt.Errorf("error during zipHandler.setup: %w", err)
}
f, err := os.Open(filepath.Join(z.tempDir, filename))
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
func (z *zipHandler) cleanup() error {
if err := os.RemoveAll(z.tempDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("os.Remove: %w", err)
}
z.files = nil
return nil
}
// Copyright 2021 OpenSSF 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 clients
import (
"context"
"errors"
"fmt"
"cloud.google.com/go/storage"
"gocloud.dev/blob"
// Needed to link GCP drivers.
_ "gocloud.dev/blob/gcsblob"
)
// blobClientCIIBestPractices implements the CIIBestPracticesClient interface.
// A gocloud blob client is used to communicate with the CII Best Practices data.
type blobClientCIIBestPractices struct {
bucketURL string
}
// GetBadgeLevel implements CIIBestPracticesClient.GetBadgeLevel.
func (client *blobClientCIIBestPractices) GetBadgeLevel(ctx context.Context, uri string) (BadgeLevel, error) {
bucket, err := blob.OpenBucket(ctx, client.bucketURL)
if err != nil {
return Unknown, fmt.Errorf("error during blob.OpenBucket: %w", err)
}
defer bucket.Close()
objectName := fmt.Sprintf("%s/result.json", uri)
exists, err := bucket.Exists(ctx, objectName)
// TODO(https://github.com/ossf/scorecard/issues/4636)
if err != nil && !errors.Is(err, storage.ErrObjectNotExist) {
return Unknown, fmt.Errorf("error during bucket.Exists: %w", err)
}
if !exists {
return NotFound, nil
}
jsonData, err := bucket.ReadAll(ctx, objectName)
if err != nil {
return Unknown, fmt.Errorf("error during bucket.ReadAll: %w", err)
}
parsedResponse, err := ParseBadgeResponseFromJSON(jsonData)
if err != nil {
return Unknown, fmt.Errorf("error parsing data: %w", err)
}
if len(parsedResponse) < 1 {
return NotFound, nil
}
return parsedResponse[0].getBadgeLevel()
}
// Copyright 2021 OpenSSF 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 clients
import (
"context"
)
const (
// Unknown or non-parsable CII Best Practices badge.
Unknown BadgeLevel = iota
// NotFound represents when CII Best Practices returns an empty response for a project.
NotFound
// InProgress state of CII Best Practices badge.
InProgress
// Passing level for CII Best Practices badge.
Passing
// Silver level for CII Best Practices badge.
Silver
// Gold level for CII Best Practices badge.
Gold
)
// BadgeLevel corresponds to CII-Best-Practices badge levels.
// https://www.bestpractices.dev/en
type BadgeLevel uint
// String returns a string value for BadgeLevel enum.
func (badge BadgeLevel) String() string {
switch badge {
case Unknown:
return "Unknown"
case NotFound:
return "not_found"
case InProgress:
return "in_progress"
case Passing:
return "passing"
case Silver:
return "silver"
case Gold:
return "gold"
default:
return ""
}
}
// CIIBestPracticesClient interface returns the BadgeLevel for a repo URL.
type CIIBestPracticesClient interface {
GetBadgeLevel(ctx context.Context, uri string) (BadgeLevel, error)
}
// DefaultCIIBestPracticesClient returns http-based implementation of the interface.
func DefaultCIIBestPracticesClient() CIIBestPracticesClient {
return &httpClientCIIBestPractices{}
}
// BlobCIIBestPracticesClient returns a blob-based implementation of the interface.
func BlobCIIBestPracticesClient(bucketURL string) CIIBestPracticesClient {
return &blobClientCIIBestPractices{
bucketURL: bucketURL,
}
}
// Copyright 2021 OpenSSF 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 clients
import (
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"time"
)
var errTooManyRequests = errors.New("failed after exponential backoff")
// httpClientCIIBestPractices implements the CIIBestPracticesClient interface.
// A HTTP client with exponential backoff is used to communicate with the CII Best Practices servers.
type httpClientCIIBestPractices struct{}
type expBackoffTransport struct {
numRetries uint8
}
func (transport *expBackoffTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for i := 0; i < int(transport.numRetries); i++ {
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
//nolint:wrapcheck
return resp, err
}
time.Sleep(time.Duration(math.Pow(2, float64(i))) * time.Second)
}
return nil, errTooManyRequests
}
// GetBadgeLevel implements CIIBestPracticesClient.GetBadgeLevel.
func (client *httpClientCIIBestPractices) GetBadgeLevel(ctx context.Context, uri string) (BadgeLevel, error) {
repoURI := fmt.Sprintf("https://%s", uri)
url := fmt.Sprintf("https://www.bestpractices.dev/projects.json?url=%s", repoURI)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Unknown, fmt.Errorf("error during http.NewRequestWithContext: %w", err)
}
httpClient := http.Client{
Transport: &expBackoffTransport{
numRetries: 3,
},
}
resp, err := httpClient.Do(req)
if err != nil {
return Unknown, fmt.Errorf("error during http.Do: %w", err)
}
defer resp.Body.Close()
jsonData, err := io.ReadAll(resp.Body)
if err != nil {
return Unknown, fmt.Errorf("error during io.ReadAll: %w", err)
}
parsedResponse, err := ParseBadgeResponseFromJSON(jsonData)
if err != nil {
return Unknown, fmt.Errorf("error during json parsing: %w", err)
}
if len(parsedResponse) < 1 {
return NotFound, nil
}
return parsedResponse[0].getBadgeLevel()
}
// Copyright 2021 OpenSSF 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 clients
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
const (
inProgressResp = "in_progress"
passingResp = "passing"
silverResp = "silver"
goldResp = "gold"
)
var errUnsupportedBadge = errors.New("unsupported badge")
// BadgeResponse struct is used to read/write CII Best Practices badge data.
type BadgeResponse struct {
BadgeLevel string `json:"badge_level"`
}
// getBadgeLevel parses a string badge value into BadgeLevel enum.
func (resp BadgeResponse) getBadgeLevel() (BadgeLevel, error) {
if strings.Contains(resp.BadgeLevel, inProgressResp) {
return InProgress, nil
}
if strings.Contains(resp.BadgeLevel, passingResp) {
return Passing, nil
}
if strings.Contains(resp.BadgeLevel, silverResp) {
return Silver, nil
}
if strings.Contains(resp.BadgeLevel, goldResp) {
return Gold, nil
}
return Unknown, fmt.Errorf("%w: %s", errUnsupportedBadge, resp.BadgeLevel)
}
// AsJSON outputs BadgeResponse struct in JSON format.
func (resp BadgeResponse) AsJSON() ([]byte, error) {
ret := []BadgeResponse{resp}
jsonData, err := json.Marshal(ret)
if err != nil {
return nil, fmt.Errorf("error during json.Marshal: %w", err)
}
return jsonData, nil
}
// ParseBadgeResponseFromJSON parses input []byte value into []BadgeResponse.
func ParseBadgeResponseFromJSON(data []byte) ([]BadgeResponse, error) {
parsedResponse := []BadgeResponse{}
if err := json.Unmarshal(data, &parsedResponse); err != nil {
return nil, fmt.Errorf("error during json.Unmarshal: %w", err)
}
return parsedResponse, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/shurcooL/githubv4"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/clients/githubrepo/internal/fnmatch"
sce "github.com/ossf/scorecard/v5/errors"
)
const (
refPrefix = "refs/heads/"
)
// See https://github.community/t/graphql-api-protected-branch/14380
/* Example of query:
query {
repository(owner: "laurentsimon", name: "test3") {
branchProtectionRules(first: 100) {
edges {
node {
allowsDeletions
allowsForcePushes
dismissesStaleReviews
isAdminEnforced
pattern
matchingRefs(first: 100) {
nodes {
name
}
}
}
}
}
refs(first: 100, refPrefix: "refs/heads/") {
nodes {
name
refUpdateRule {
requiredApprovingReviewCount
allowsForcePushes
}
}
}
rulesets(first: 100) {
edges {
node {
name
enforcement
target
conditions {
refName {
exclude
include
}
}
bypassActors(first: 100) {
nodes {
actor {
__typename
... on App {
name
databaseId
}
}
bypassMode
organizationAdmin
repositoryRoleName
}
}
rules(first: 100) {
nodes {
type
parameters {
... on PullRequestParameters {
dismissStaleReviewsOnPush
requireCodeOwnerReview
requireLastPushApproval
requiredApprovingReviewCount
requiredReviewThreadResolution
}
... on RequiredStatusChecksParameters {
requiredStatusChecks {
context
integrationId
}
strictRequiredStatusChecksPolicy
}
}
}
}
}
}
}
}
}
*/
// Used for non-admin settings.
type refUpdateRule struct {
AllowsDeletions *bool
AllowsForcePushes *bool
RequiredApprovingReviewCount *int32
RequiresCodeOwnerReviews *bool
RequiresLinearHistory *bool
RequiredStatusCheckContexts []string
}
// Used for all settings, both admin and non-admin ones.
// This only works with an admin token.
type branchProtectionRule struct {
DismissesStaleReviews *bool
IsAdminEnforced *bool
RequiresStrictStatusChecks *bool
RequiresStatusChecks *bool
AllowsDeletions *bool
AllowsForcePushes *bool
RequiredApprovingReviewCount *int32
RequiresCodeOwnerReviews *bool
RequiresLinearHistory *bool
RequireLastPushApproval *bool
RequiredStatusCheckContexts []string
// TODO: verify there is no conflicts.
// BranchProtectionRuleConflicts interface{}
}
type branch struct {
Name *string
RefUpdateRule *refUpdateRule
BranchProtectionRule *branchProtectionRule
}
type defaultBranchData struct {
Repository struct {
DefaultBranchRef *branch
} `graphql:"repository(owner: $owner, name: $name)"`
RateLimit struct {
Cost *int
}
}
type pullRequestRuleParameters struct {
DismissStaleReviewsOnPush *bool
RequireCodeOwnerReview *bool
RequireLastPushApproval *bool
RequiredApprovingReviewCount *int32
RequiredReviewThreadResolution *bool
}
type requiredStatusCheckParameters struct {
StrictRequiredStatusChecksPolicy *bool
RequiredStatusChecks []statusCheck
}
type statusCheck struct {
Context *string
IntegrationID *int64
}
type repoRule struct {
Type string
Parameters repoRulesParameters
}
type repoRulesParameters struct {
PullRequestParameters pullRequestRuleParameters `graphql:"... on PullRequestParameters"`
StatusCheckParameters requiredStatusCheckParameters `graphql:"... on RequiredStatusChecksParameters"`
}
type ruleSetConditionRefs struct {
Include []string
Exclude []string
}
type ruleSetCondition struct {
RefName ruleSetConditionRefs
}
type ruleSetBypass struct {
BypassMode *string
OrganizationAdmin *bool
RepositoryRoleName *string
}
type repoRuleSet struct {
Name *string
Enforcement *string
Target *string
Conditions ruleSetCondition
BypassActors struct {
Nodes []*ruleSetBypass
} `graphql:"bypassActors(first: 100)"`
Rules struct {
Nodes []*repoRule
} `graphql:"rules(first: 100)"`
}
type ruleSetData struct {
Repository struct {
DefaultBranchRef struct {
Name *string
}
Rulesets struct {
Nodes []*repoRuleSet
} `graphql:"rulesets(first: 100)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
type branchData struct {
Repository struct {
Ref *branch `graphql:"ref(qualifiedName: $branchRefName)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
type branchesHandler struct {
ghClient *github.Client
graphClient *githubv4.Client
data *defaultBranchData
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
defaultBranchRef *clients.BranchRef
defaultBranchName string
ruleSets []*repoRuleSet
}
func (handler *branchesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.defaultBranchRef = nil
handler.defaultBranchName = ""
handler.ruleSets = nil
handler.data = nil
}
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
}
vars := map[string]interface{}{
"owner": githubv4.String(handler.repourl.owner),
"name": githubv4.String(handler.repourl.repo),
}
// Fetch default branch name and any repository rulesets, which are available with basic read permission.
rulesData := new(ruleSetData)
if err := handler.graphClient.Query(handler.ctx, rulesData, vars); err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err))
return
}
handler.defaultBranchName = getDefaultBranchNameFrom(rulesData)
handler.ruleSets = getActiveRuleSetsFrom(rulesData)
// Attempt to fetch branch protection rules, which require admin permission.
// Ignore permissions errors if we know the repository is using rulesets, so non-admins can still get a score.
handler.data = new(defaultBranchData)
if err := handler.graphClient.Query(handler.ctx, handler.data, vars); err != nil &&
(!isPermissionsError(err) || len(handler.ruleSets) == 0) {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err))
return
}
rules, err := rulesMatchingBranch(handler.ruleSets, handler.defaultBranchName, true)
if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("rulesMatchingBranch: %v", err))
return
}
handler.defaultBranchRef = getBranchRefFrom(handler.data.Repository.DefaultBranchRef, rules)
})
return handler.errSetup
}
func (handler *branchesHandler) query(branchName string) (*clients.BranchRef, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return nil, fmt.Errorf("%w: branches only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
// Call setup(), so we know if branchName == handler.defaultBranchName.
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during branchesHandler.setup: %w", err)
}
vars := map[string]interface{}{
"owner": githubv4.String(handler.repourl.owner),
"name": githubv4.String(handler.repourl.repo),
"branchRefName": githubv4.String(refPrefix + branchName),
}
queryData := new(branchData)
if err := handler.graphClient.Query(handler.ctx, queryData, vars); err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err))
}
rules, err := rulesMatchingBranch(handler.ruleSets, branchName, branchName == handler.defaultBranchName)
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("rulesMatchingBranch: %v", err))
}
return getBranchRefFrom(queryData.Repository.Ref, rules), nil
}
func (handler *branchesHandler) getDefaultBranch() (*clients.BranchRef, error) {
if err := handler.setup(); 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) {
branchRef, err := handler.query(branch)
if err != nil {
return nil, fmt.Errorf("error during branchesHandler.query: %w", err)
}
return branchRef, nil
}
// TODO: Move these two functions to below the GetBranchRefFrom functions, the single place they're used.
func copyAdminSettings(src *branchProtectionRule, dst *clients.BranchProtectionRule) {
copyBoolPtr(src.IsAdminEnforced, &dst.EnforceAdmins)
copyBoolPtr(src.RequireLastPushApproval, &dst.RequireLastPushApproval)
copyBoolPtr(src.DismissesStaleReviews, &dst.PullRequestRule.DismissStaleReviews)
if src.RequiresStatusChecks != nil {
copyBoolPtr(src.RequiresStatusChecks, &dst.CheckRules.RequiresStatusChecks)
// TODO(#3255): Update when GitHub GraphQL bug is fixed
// Workaround for GitHub GraphQL bug https://github.com/orgs/community/discussions/59471
// The setting RequiresStrictStatusChecks should tell if the branch is required
// to be up to date before merge, but it only returns the correct value if
// RequiresStatusChecks is true. If RequiresStatusChecks is false, RequiresStrictStatusChecks
// is wrongly retrieved as true.
if src.RequiresStrictStatusChecks != nil {
upToDateBeforeMerge := *src.RequiresStatusChecks && *src.RequiresStrictStatusChecks
copyBoolPtr(&upToDateBeforeMerge, &dst.CheckRules.UpToDateBeforeMerge)
}
}
// we always have the data to know if PRs are required
if dst.PullRequestRule.Required == nil {
dst.PullRequestRule.Required = asPtr(false)
}
// these values report as &false when PRs aren't required, so if they're true then PRs are required
if valueOrZero(src.RequireLastPushApproval) || valueOrZero(src.DismissesStaleReviews) {
dst.PullRequestRule.Required = asPtr(true)
}
}
func copyNonAdminSettings(src interface{}, dst *clients.BranchProtectionRule) {
// TODO: requiresConversationResolution, requiresSignatures, viewerAllowedToDismissReviews, viewerCanPush
switch v := src.(type) {
case *branchProtectionRule:
copyBoolPtr(v.AllowsDeletions, &dst.AllowDeletions)
copyBoolPtr(v.AllowsForcePushes, &dst.AllowForcePushes)
copyBoolPtr(v.RequiresLinearHistory, &dst.RequireLinearHistory)
copyInt32Ptr(v.RequiredApprovingReviewCount, &dst.PullRequestRule.RequiredApprovingReviewCount)
copyBoolPtr(v.RequiresCodeOwnerReviews, &dst.PullRequestRule.RequireCodeOwnerReviews)
copyStringSlice(v.RequiredStatusCheckContexts, &dst.CheckRules.Contexts)
// we always have the data to know if PRs are required
if dst.PullRequestRule.Required == nil {
dst.PullRequestRule.Required = asPtr(false)
}
// GitHub returns nil for RequiredApprovingReviewCount when PRs aren't required and non-nil when they are
// RequiresCodeOwnerReviews is &false even if PRs aren't required, so we need it to be true
if v.RequiredApprovingReviewCount != nil || valueOrZero(v.RequiresCodeOwnerReviews) {
dst.PullRequestRule.Required = asPtr(true)
}
case *refUpdateRule:
copyBoolPtr(v.AllowsDeletions, &dst.AllowDeletions)
copyBoolPtr(v.AllowsForcePushes, &dst.AllowForcePushes)
copyBoolPtr(v.RequiresLinearHistory, &dst.RequireLinearHistory)
copyInt32Ptr(v.RequiredApprovingReviewCount, &dst.PullRequestRule.RequiredApprovingReviewCount)
copyBoolPtr(v.RequiresCodeOwnerReviews, &dst.PullRequestRule.RequireCodeOwnerReviews)
copyStringSlice(v.RequiredStatusCheckContexts, &dst.CheckRules.Contexts)
// Evaluate if we have data to infer that the project requires PRs to make changes. If we don't have data, we let
// Required stay nil
if valueOrZero(v.RequiredApprovingReviewCount) > 0 || valueOrZero(v.RequiresCodeOwnerReviews) {
dst.PullRequestRule.Required = asPtr(true)
}
}
}
func getDefaultBranchNameFrom(data *ruleSetData) string {
if data == nil || data.Repository.DefaultBranchRef.Name == nil {
return ""
}
return *data.Repository.DefaultBranchRef.Name
}
func getActiveRuleSetsFrom(data *ruleSetData) []*repoRuleSet {
ret := make([]*repoRuleSet, 0)
for _, rule := range data.Repository.Rulesets.Nodes {
if rule.Enforcement == nil || *rule.Enforcement != "ACTIVE" {
continue
}
ret = append(ret, rule)
}
return ret
}
func getBranchRefFrom(data *branch, rules []*repoRuleSet) *clients.BranchRef {
if data == nil {
return nil
}
branchRef := new(clients.BranchRef)
if data.Name != nil {
branchRef.Name = data.Name
}
// Protected means we found some data,
// i.e., there's a rule for the branch.
// It says nothing about what protection is enabled at all.
branchRef.Protected = new(bool)
if data.RefUpdateRule == nil &&
data.BranchProtectionRule == nil &&
len(rules) == 0 {
*branchRef.Protected = false
return branchRef
}
*branchRef.Protected = true
branchRule := &branchRef.BranchProtectionRule
switch {
// All settings are available. This typically means
// scorecard is run with a token that has access
// to admin settings.
case data.BranchProtectionRule != nil:
rule := data.BranchProtectionRule
// Non-admin settings.
copyNonAdminSettings(rule, branchRule)
// Admin settings.
copyAdminSettings(rule, branchRule)
// Only non-admin settings are available.
// https://docs.github.com/en/graphql/reference/objects#refupdaterule.
case data.RefUpdateRule != nil:
rule := data.RefUpdateRule
copyNonAdminSettings(rule, branchRule)
}
applyRepoRules(branchRef, rules)
return branchRef
}
func isPermissionsError(err error) bool {
return strings.Contains(err.Error(), "Resource not accessible")
}
const (
ruleConditionDefaultBranch = "~DEFAULT_BRANCH"
ruleConditionAllBranches = "~ALL"
ruleDeletion = "DELETION"
ruleForcePush = "NON_FAST_FORWARD"
ruleLinear = "REQUIRED_LINEAR_HISTORY"
rulePullRequest = "PULL_REQUEST"
ruleStatusCheck = "REQUIRED_STATUS_CHECKS"
)
func rulesMatchingBranch(rules []*repoRuleSet, name string, defaultRef bool) ([]*repoRuleSet, error) {
refName := refPrefix + name
ret := make([]*repoRuleSet, 0)
nextRule:
for _, rule := range rules {
// Skip rulesets that don't target branches
if rule.Target != nil && *rule.Target != "BRANCH" {
continue
}
for _, cond := range rule.Conditions.RefName.Exclude {
if match, err := fnmatch.Match(cond, refName); err != nil {
return nil, fmt.Errorf("exclude match error: %w", err)
} else if match {
continue nextRule
}
}
for _, cond := range rule.Conditions.RefName.Include {
if cond == ruleConditionAllBranches {
ret = append(ret, rule)
break
}
if cond == ruleConditionDefaultBranch && defaultRef {
ret = append(ret, rule)
break
}
if match, err := fnmatch.Match(cond, refName); err != nil {
return nil, fmt.Errorf("include match error: %w", err)
} else if match {
ret = append(ret, rule)
}
}
}
return ret, nil
}
func applyRepoRules(branchRef *clients.BranchRef, rules []*repoRuleSet) {
for _, r := range rules {
// Init values of base checkbox as if they're unchecked
translated := clients.BranchProtectionRule{
AllowDeletions: asPtr(true),
AllowForcePushes: asPtr(true),
RequireLinearHistory: asPtr(false),
PullRequestRule: clients.PullRequestRule{
Required: asPtr(false),
},
}
translated.EnforceAdmins = asPtr(len(r.BypassActors.Nodes) == 0)
for _, rule := range r.Rules.Nodes {
switch rule.Type {
case ruleDeletion:
translated.AllowDeletions = asPtr(false)
case ruleForcePush:
translated.AllowForcePushes = asPtr(false)
case ruleLinear:
translated.RequireLinearHistory = asPtr(true)
case rulePullRequest:
translatePullRequestRepoRule(&translated, rule)
case ruleStatusCheck:
translateRequiredStatusRepoRule(&translated, rule)
}
}
mergeBranchProtectionRules(&branchRef.BranchProtectionRule, &translated)
}
}
func translatePullRequestRepoRule(base *clients.BranchProtectionRule, rule *repoRule) {
base.PullRequestRule.Required = asPtr(true)
base.PullRequestRule.DismissStaleReviews = rule.Parameters.PullRequestParameters.DismissStaleReviewsOnPush
base.PullRequestRule.RequireCodeOwnerReviews = rule.Parameters.PullRequestParameters.RequireCodeOwnerReview
base.RequireLastPushApproval = rule.Parameters.PullRequestParameters.RequireLastPushApproval
base.PullRequestRule.RequiredApprovingReviewCount = rule.Parameters.PullRequestParameters.
RequiredApprovingReviewCount
}
func translateRequiredStatusRepoRule(base *clients.BranchProtectionRule, rule *repoRule) {
statusParams := rule.Parameters.StatusCheckParameters
if len(statusParams.RequiredStatusChecks) == 0 {
return
}
base.CheckRules.RequiresStatusChecks = asPtr(true)
base.CheckRules.UpToDateBeforeMerge = statusParams.StrictRequiredStatusChecksPolicy
for _, chk := range statusParams.RequiredStatusChecks {
if chk.Context == nil {
continue
}
base.CheckRules.Contexts = append(base.CheckRules.Contexts, *chk.Context)
}
}
// Merge strategy:
// - if both are nil, keep it nil
// - if any of them is not nil, keep the most restrictive one
func mergeBranchProtectionRules(base, translated *clients.BranchProtectionRule) {
if base.AllowDeletions == nil || (translated.AllowDeletions != nil && !*translated.AllowDeletions) {
base.AllowDeletions = translated.AllowDeletions
}
if base.AllowForcePushes == nil || (translated.AllowForcePushes != nil && !*translated.AllowForcePushes) {
base.AllowForcePushes = translated.AllowForcePushes
}
if base.EnforceAdmins == nil || (translated.EnforceAdmins != nil && !*translated.EnforceAdmins) {
// this is an over simplification to get preliminary support for repo rules merged.
// A more complete approach would process all rules without bypass actors first,
// then process those with bypass actors. If no settings improve (due to rule layering),
// then we can ignore the bypass actors.
// https://github.com/ossf/scorecard/issues/3480
base.EnforceAdmins = translated.EnforceAdmins
}
if base.RequireLastPushApproval == nil || valueOrZero(translated.RequireLastPushApproval) {
base.RequireLastPushApproval = translated.RequireLastPushApproval
}
if base.RequireLinearHistory == nil || valueOrZero(translated.RequireLinearHistory) {
base.RequireLinearHistory = translated.RequireLinearHistory
}
mergeCheckRules(&base.CheckRules, &translated.CheckRules)
mergePullRequestReviews(&base.PullRequestRule, &translated.PullRequestRule)
}
func mergeCheckRules(base, translated *clients.StatusChecksRule) {
if base.UpToDateBeforeMerge == nil || valueOrZero(translated.UpToDateBeforeMerge) {
base.UpToDateBeforeMerge = translated.UpToDateBeforeMerge
}
if base.RequiresStatusChecks == nil || valueOrZero(translated.RequiresStatusChecks) {
base.RequiresStatusChecks = translated.RequiresStatusChecks
}
for _, context := range translated.Contexts {
// this isn't optimal, but probably not a bottleneck.
if !slices.Contains(base.Contexts, context) {
base.Contexts = append(base.Contexts, context)
}
}
}
func mergePullRequestReviews(base, translated *clients.PullRequestRule) {
if base.Required == nil || valueOrZero(translated.Required) {
base.Required = translated.Required
}
if base.RequiredApprovingReviewCount == nil ||
valueOrZero(base.RequiredApprovingReviewCount) < valueOrZero(translated.RequiredApprovingReviewCount) {
base.RequiredApprovingReviewCount = translated.RequiredApprovingReviewCount
}
if base.DismissStaleReviews == nil || valueOrZero(translated.DismissStaleReviews) {
base.DismissStaleReviews = translated.DismissStaleReviews
}
if base.RequireCodeOwnerReviews == nil || valueOrZero(translated.RequireCodeOwnerReviews) {
base.RequireCodeOwnerReviews = translated.RequireCodeOwnerReviews
}
}
// returns a pointer to the given value. Useful for constant values.
func asPtr[T any](value T) *T {
return &value
}
// returns the pointer's value if it exists, the type's zero-value otherwise.
func valueOrZero[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/shurcooL/githubv4"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/log"
)
//nolint:govet
type checkRunsGraphqlData struct {
Repository struct {
Object struct {
Commit struct {
History struct {
Nodes []struct {
AssociatedPullRequests struct {
Nodes []struct {
HeadRefOid githubv4.String
Commits struct {
Nodes []struct {
Commit struct {
CheckSuites struct {
Nodes []struct {
App struct {
Slug githubv4.String
}
Conclusion githubv4.CheckConclusionState
Status githubv4.CheckStatusState
}
} `graphql:"checkSuites(first: $checksToAnalyze)"`
}
}
} `graphql:"commits(last:1)"`
}
} `graphql:"associatedPullRequests(first: $pullRequestsToAnalyze)"`
}
} `graphql:"history(first: $commitsToAnalyze)"`
} `graphql:"... on Commit"`
} `graphql:"object(expression: $commitExpression)"`
} `graphql:"repository(owner: $owner, name: $name)"`
RateLimit struct {
Cost *int
}
}
type checkRunsByRef = map[string][]clients.CheckRun
//nolint:govet
type checkrunsHandler struct {
client *github.Client
graphClient *githubv4.Client
repourl *Repo
logger *log.Logger
checkData *checkRunsGraphqlData
setupOnce *sync.Once
ctx context.Context
commitDepth int
checkRunsByRef checkRunsByRef
errSetup error
}
func (handler *checkrunsHandler) init(ctx context.Context, repourl *Repo, commitDepth int) {
handler.ctx = ctx
handler.repourl = repourl
handler.commitDepth = commitDepth
handler.logger = log.NewLogger(log.DefaultLevel)
handler.checkData = new(checkRunsGraphqlData)
handler.setupOnce = new(sync.Once)
handler.checkRunsByRef = checkRunsByRef{}
handler.errSetup = nil
}
func (handler *checkrunsHandler) setup() error {
handler.setupOnce.Do(func() {
handler.errSetup = nil
commitExpression := handler.repourl.commitExpression()
vars := map[string]interface{}{
"owner": githubv4.String(handler.repourl.owner),
"name": githubv4.String(handler.repourl.repo),
"pullRequestsToAnalyze": githubv4.Int(pullRequestsToAnalyze),
"commitsToAnalyze": githubv4.Int(handler.commitDepth),
"commitExpression": githubv4.String(commitExpression),
"checksToAnalyze": githubv4.Int(checksToAnalyze),
}
// TODO(#2224):
// sast and ci checks causes cache miss if commits dont match number of check runs.
// paging for this needs to be implemented if using higher than 100 --number-of-commits
if handler.commitDepth > 99 {
vars["commitsToAnalyze"] = githubv4.Int(99)
}
if err := handler.graphClient.Query(handler.ctx, handler.checkData, vars); err != nil {
// quit early without setting crsErrSetup for "Resource not accessible by integration" error
// for whatever reason, this check doesn't work with a GITHUB_TOKEN, only a PAT
if strings.Contains(err.Error(), "Resource not accessible by integration") {
return
}
handler.errSetup = err
return
}
handler.checkRunsByRef = parseCheckRuns(handler.checkData)
})
return handler.errSetup
}
func (handler *checkrunsHandler) listCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during graphqlHandler.setupCheckRuns: %w", err)
}
if crs, ok := handler.checkRunsByRef[ref]; ok {
return crs, nil
}
msg := fmt.Sprintf("listCheckRunsForRef cache miss: %s/%s:%s", handler.repourl.owner, handler.repourl.repo, ref)
handler.logger.Info(msg)
checkRuns, _, err := handler.client.Checks.ListCheckRunsForRef(
handler.ctx, handler.repourl.owner, handler.repourl.repo, ref, &github.ListCheckRunsOptions{})
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("ListCheckRunsForRef: %v", err))
}
handler.checkRunsByRef[ref] = checkRunsFrom(checkRuns)
return handler.checkRunsByRef[ref], nil
}
func parseCheckRuns(data *checkRunsGraphqlData) checkRunsByRef {
checkCache := checkRunsByRef{}
for _, commit := range data.Repository.Object.Commit.History.Nodes {
for _, pr := range commit.AssociatedPullRequests.Nodes {
var crs []clients.CheckRun
for _, c := range pr.Commits.Nodes {
for _, checkRun := range c.Commit.CheckSuites.Nodes {
crs = append(crs, clients.CheckRun{
// the REST API returns lowercase. the graphQL API returns upper
Status: strings.ToLower(string(checkRun.Status)),
Conclusion: strings.ToLower(string(checkRun.Conclusion)),
App: clients.CheckRunApp{
Slug: string(checkRun.App.Slug),
},
})
}
}
headRef := string(pr.HeadRefOid)
checkCache[headRef] = crs
}
}
return checkCache
}
func checkRunsFrom(data *github.ListCheckRunsResults) []clients.CheckRun {
var checkRuns []clients.CheckRun
for _, checkRun := range data.CheckRuns {
checkRuns = append(checkRuns, clients.CheckRun{
Status: checkRun.GetStatus(),
Conclusion: checkRun.GetConclusion(),
URL: checkRun.GetURL(),
App: clients.CheckRunApp{
Slug: checkRun.GetApp().GetSlug(),
},
})
}
return checkRuns
}
// Copyright 2021 OpenSSF 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 githubrepo implements clients.RepoClient for GitHub.
package githubrepo
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/go-github/v53/github"
"github.com/shurcooL/githubv4"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/internal/gitfile"
"github.com/ossf/scorecard/v5/log"
)
var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type repoURL")
errDefaultBranchEmpty = errors.New("default branch name is empty")
)
type Option func(*repoClientConfig) error
// Client is GitHub-specific implementation of RepoClient.
type Client struct {
repourl *Repo
repo *github.Repository
repoClient *github.Client
graphClient *graphqlHandler
contributors *contributorsHandler
branches *branchesHandler
releases *releasesHandler
workflows *workflowsHandler
checkruns *checkrunsHandler
statuses *statusesHandler
search *searchHandler
searchCommits *searchCommitsHandler
webhook *webhookHandler
languages *languagesHandler
licenses *licensesHandler
git *gitfile.Handler
ctx context.Context
tarball tarballHandler
commitDepth int
gitMode bool
}
// WithFileModeGit configures the repo client to fetch files using git.
func WithFileModeGit() Option {
return func(c *repoClientConfig) error {
c.gitMode = true
return nil
}
}
// WithRoundTripper configures the repo client to use the specified http.RoundTripper.
func WithRoundTripper(rt http.RoundTripper) Option {
return func(c *repoClientConfig) error {
c.rt = rt
return nil
}
}
type repoClientConfig struct {
rt http.RoundTripper
gitMode bool
}
const defaultGhHost = "github.com"
// InitRepo sets up the GitHub repo in local storage for improving performance and GitHub token usage efficiency.
func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error {
ghRepo, ok := inputRepo.(*Repo)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}
// Sanity check.
repo, _, err := client.repoClient.Repositories.Get(client.ctx, ghRepo.owner, ghRepo.repo)
if err != nil {
return sce.WithMessage(sce.ErrRepoUnreachable, err.Error())
}
if commitDepth <= 0 {
commitDepth = 30 // default
}
client.commitDepth = commitDepth
client.repo = repo
client.repourl = &Repo{
owner: repo.Owner.GetLogin(),
repo: repo.GetName(),
defaultBranch: repo.GetDefaultBranch(),
commitSHA: commitSHA,
}
if client.gitMode {
client.git.Init(client.ctx, client.repo.GetCloneURL(), commitSHA)
} else {
// Init tarballHandler.
client.tarball.init(client.ctx, client.repo, commitSHA)
}
// Setup GraphQL.
client.graphClient.init(client.ctx, client.repourl, client.commitDepth)
// Setup contributorsHandler.
client.contributors.init(client.ctx, client.repourl)
// Setup branchesHandler.
client.branches.init(client.ctx, client.repourl)
// Setup releasesHandler.
client.releases.init(client.ctx, client.repourl)
// Setup workflowsHandler.
client.workflows.init(client.ctx, client.repourl)
// Setup checkrunsHandler.
client.checkruns.init(client.ctx, client.repourl, client.commitDepth)
// Setup statusesHandler.
client.statuses.init(client.ctx, client.repourl)
// Setup searchHandler.
client.search.init(client.ctx, client.repourl)
// Setup searchCommitsHandler
client.searchCommits.init(client.ctx, client.repourl)
// Setup webhookHandler.
client.webhook.init(client.ctx, client.repourl)
// Setup languagesHandler.
client.languages.init(client.ctx, client.repourl)
// Setup licensesHandler.
client.licenses.init(client.ctx, client.repourl)
return nil
}
// URI implements RepoClient.URI.
func (client *Client) URI() string {
host, isHost := os.LookupEnv("GH_HOST")
if !isHost {
host = defaultGhHost
}
return fmt.Sprintf("%s/%s/%s", host, client.repourl.owner, client.repourl.repo)
}
// LocalPath implements RepoClient.LocalPath.
func (client *Client) LocalPath() (string, error) {
if client.gitMode {
path, err := client.git.GetLocalPath()
if err != nil {
return "", fmt.Errorf("git local path: %w", err)
}
return path, nil
}
return client.tarball.getLocalPath()
}
// ListFiles implements RepoClient.ListFiles.
func (client *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
if client.gitMode {
files, err := client.git.ListFiles(predicate)
if err != nil {
return nil, fmt.Errorf("git listfiles: %w", err)
}
return files, nil
}
return client.tarball.listFiles(predicate)
}
// GetFileReader implements RepoClient.GetFileReader.
func (client *Client) GetFileReader(filename string) (io.ReadCloser, error) {
if client.gitMode {
f, err := client.git.GetFile(filename)
if err != nil {
return nil, fmt.Errorf("git getfile: %w", err)
}
return f, nil
}
return client.tarball.getFile(filename)
}
// ListCommits implements RepoClient.ListCommits.
func (client *Client) ListCommits() ([]clients.Commit, error) {
return client.graphClient.getCommits()
}
// ListIssues implements RepoClient.ListIssues.
func (client *Client) ListIssues() ([]clients.Issue, error) {
// here you would need to pass commitDepth or something
return client.graphClient.getIssues()
}
// ListReleases implements RepoClient.ListReleases.
func (client *Client) ListReleases() ([]clients.Release, error) {
return client.releases.getReleases()
}
// ListContributors implements RepoClient.ListContributors.
func (client *Client) ListContributors() ([]clients.User, error) {
var fileReader io.ReadCloser
for _, path := range codeOwnerPaths {
var err error
fileReader, err = client.GetFileReader(path)
if err == nil {
break
}
}
return client.contributors.getContributors(fileReader)
}
// IsArchived implements RepoClient.IsArchived.
func (client *Client) IsArchived() (bool, error) {
return client.graphClient.isArchived()
}
// GetDefaultBranch implements RepoClient.GetDefaultBranch.
func (client *Client) GetDefaultBranch() (*clients.BranchRef, error) {
return client.branches.getDefaultBranch()
}
// GetDefaultBranchName implements RepoClient.GetDefaultBranchName.
func (client *Client) GetDefaultBranchName() (string, error) {
if len(client.repourl.defaultBranch) > 0 {
return client.repourl.defaultBranch, nil
}
return "", fmt.Errorf("%w", errDefaultBranchEmpty)
}
// GetBranch implements RepoClient.GetBranch.
func (client *Client) GetBranch(branch string) (*clients.BranchRef, error) {
return client.branches.getBranch(branch)
}
// GetCreatedAt is a getter for repo.CreatedAt.
func (client *Client) GetCreatedAt() (time.Time, error) {
return client.repo.CreatedAt.Time, nil
}
func (client *Client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
dotGithubRepo, err := MakeGithubRepo(fmt.Sprintf("%s/.github", client.repourl.owner))
if err != nil {
return nil, fmt.Errorf("error during MakeGithubRepo: %w", err)
}
options := []Option{WithRoundTripper(client.repoClient.Client().Transport)}
if client.gitMode {
options = append(options, WithFileModeGit())
}
c, err := NewRepoClient(ctx, options...)
if err != nil {
return nil, fmt.Errorf("create org repoclient: %w", err)
}
if err := c.InitRepo(dotGithubRepo, clients.HeadSHA, 0); err != nil {
return nil, fmt.Errorf("error during InitRepo: %w", err)
}
return c, nil
}
// ListWebhooks implements RepoClient.ListWebhooks.
func (client *Client) ListWebhooks() ([]clients.Webhook, error) {
return client.webhook.listWebhooks()
}
// ListSuccessfulWorkflowRuns implements RepoClient.WorkflowRunsByFilename.
func (client *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return client.workflows.listSuccessfulWorkflowRuns(filename)
}
// ListCheckRunsForRef implements RepoClient.ListCheckRunsForRef.
func (client *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return client.checkruns.listCheckRunsForRef(ref)
}
// ListStatuses implements RepoClient.ListStatuses.
func (client *Client) ListStatuses(ref string) ([]clients.Status, error) {
return client.statuses.listStatuses(ref)
}
// ListProgrammingLanguages implements RepoClient.ListProgrammingLanguages.
func (client *Client) ListProgrammingLanguages() ([]clients.Language, error) {
return client.languages.listProgrammingLanguages()
}
// ListLicenses implements RepoClient.ListLicenses.
func (client *Client) ListLicenses() ([]clients.License, error) {
return client.licenses.listLicenses()
}
// Search implements RepoClient.Search.
func (client *Client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
return client.search.search(request)
}
// SearchCommits implements RepoClient.SearchCommits.
func (client *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return client.searchCommits.search(request)
}
// Close implements RepoClient.Close.
func (client *Client) Close() error {
if client.gitMode {
if err := client.git.Cleanup(); err != nil {
return fmt.Errorf("git cleanup: %w", err)
}
return nil
}
return client.tarball.cleanup()
}
// CreateGithubRepoClientWithTransport returns a Client which implements RepoClient interface.
func CreateGithubRepoClientWithTransport(ctx context.Context, rt http.RoundTripper) clients.RepoClient {
//nolint:errcheck // need to suppress because this method doesn't return an error
rc, _ := NewRepoClient(ctx, WithRoundTripper(rt))
return rc
}
// NewRepoClient returns a Client which implements RepoClient interface.
// It can be configured with various [Option]s.
func NewRepoClient(ctx context.Context, opts ...Option) (clients.RepoClient, error) {
var config repoClientConfig
for _, option := range opts {
if err := option(&config); err != nil {
return nil, err
}
}
if config.rt == nil {
logger := log.NewLogger(log.DefaultLevel)
config.rt = roundtripper.NewTransport(ctx, logger)
}
httpClient := &http.Client{
Transport: config.rt,
}
var client *github.Client
var graphClient *githubv4.Client
githubHost, isGhHost := os.LookupEnv("GH_HOST")
if isGhHost && githubHost != defaultGhHost {
githubRestURL := fmt.Sprintf("https://%s/api/v3", strings.TrimSpace(githubHost))
githubGraphqlURL := fmt.Sprintf("https://%s/api/graphql", strings.TrimSpace(githubHost))
var err error
client, err = github.NewEnterpriseClient(githubRestURL, githubRestURL, httpClient)
if err != nil {
panic(fmt.Errorf("error during CreateGithubRepoClientWithTransport:EnterpriseClient: %w", err))
}
graphClient = githubv4.NewEnterpriseClient(githubGraphqlURL, httpClient)
} else {
client = github.NewClient(httpClient)
graphClient = githubv4.NewClient(httpClient)
}
return &Client{
ctx: ctx,
repoClient: client,
graphClient: &graphqlHandler{
client: graphClient,
},
contributors: &contributorsHandler{
ghClient: client,
},
branches: &branchesHandler{
ghClient: client,
graphClient: graphClient,
},
releases: &releasesHandler{
client: client,
},
workflows: &workflowsHandler{
client: client,
},
checkruns: &checkrunsHandler{
client: client,
graphClient: graphClient,
},
statuses: &statusesHandler{
client: client,
},
search: &searchHandler{
ghClient: client,
},
searchCommits: &searchCommitsHandler{
ghClient: client,
},
webhook: &webhookHandler{
ghClient: client,
},
languages: &languagesHandler{
ghclient: client,
},
licenses: &licensesHandler{
ghclient: client,
},
tarball: tarballHandler{
httpClient: httpClient,
},
gitMode: config.gitMode,
git: &gitfile.Handler{},
}, nil
}
// CreateGithubRepoClient returns a Client which implements RepoClient interface.
func CreateGithubRepoClient(ctx context.Context, logger *log.Logger) clients.RepoClient {
// Use our custom roundtripper
rt := roundtripper.NewTransport(ctx, logger)
return CreateGithubRepoClientWithTransport(ctx, rt)
}
// CreateOssFuzzRepoClient returns a RepoClient implementation
// initialized to `google/oss-fuzz` GitHub repository.
//
// Deprecated: Searching the github.com/google/oss-fuzz repo for projects is flawed. Use a constructor
// from clients/ossfuzz instead. https://github.com/ossf/scorecard/issues/2670
func CreateOssFuzzRepoClient(ctx context.Context, logger *log.Logger) (clients.RepoClient, error) {
ossFuzzRepo, err := MakeGithubRepo("google/oss-fuzz")
if err != nil {
return nil, fmt.Errorf("error during MakeGithubRepo: %w", err)
}
ossFuzzRepoClient := CreateGithubRepoClient(ctx, logger)
if err := ossFuzzRepoClient.InitRepo(ossFuzzRepo, clients.HeadSHA, 0); err != nil {
return nil, fmt.Errorf("error during InitRepo: %w", err)
}
return ossFuzzRepoClient, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"io"
"maps"
"net/http"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/hmarr/codeowners"
"github.com/ossf/scorecard/v5/clients"
)
// these are the paths where CODEOWNERS files can be found see
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location
//
//nolint:lll
var (
codeOwnerPaths []string = []string{"CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"}
)
type contributorsHandler struct {
ghClient *github.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
contributors []clients.User
}
func (handler *contributorsHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.contributors = nil
}
func (handler *contributorsHandler) setup(codeOwnerFile io.ReadCloser) error {
defer codeOwnerFile.Close()
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
}
contributors := make(map[string]clients.User)
mapContributors(handler, contributors)
if handler.errSetup != nil {
return
}
mapCodeOwners(handler, codeOwnerFile, contributors)
if handler.errSetup != nil {
return
}
for contributor := range maps.Values(contributors) {
orgs, _, err := handler.ghClient.Organizations.List(handler.ctx, contributor.Login, nil)
// This call can fail due to token scopes. So ignore error.
if err == nil {
for _, org := range orgs {
contributor.Organizations = append(contributor.Organizations, clients.User{
Login: org.GetLogin(),
})
}
}
user, _, err := handler.ghClient.Users.Get(handler.ctx, contributor.Login)
if err != nil {
handler.errSetup = fmt.Errorf("error during Users.Get: %w", err)
}
contributor.Companies = append(contributor.Companies, user.GetCompany())
handler.contributors = append(handler.contributors, contributor)
}
handler.errSetup = nil
})
return handler.errSetup
}
func mapContributors(handler *contributorsHandler, contributors map[string]clients.User) {
// getting contributors from the github API
contribs, _, err := handler.ghClient.Repositories.ListContributors(
handler.ctx, handler.repourl.owner, handler.repourl.repo, &github.ListContributorsOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("error during ListContributors: %w", err)
return
}
// adding contributors to contributor map
for _, contrib := range contribs {
if contrib.GetLogin() == "" {
continue
}
contributors[contrib.GetLogin()] = clients.User{
Login: contrib.GetLogin(), NumContributions: contrib.GetContributions(),
IsCodeOwner: false,
}
}
}
func mapCodeOwners(handler *contributorsHandler, codeOwnerFile io.ReadCloser, contributors map[string]clients.User) {
ruleset, err := codeowners.ParseFile(codeOwnerFile)
if err != nil {
handler.errSetup = fmt.Errorf("error during ParseFile: %w", err)
return
}
// expanding owners
owners := make([]*clients.User, 0)
for _, rule := range ruleset {
for _, owner := range rule.Owners {
switch owner.Type {
case codeowners.UsernameOwner:
// if usernameOwner just add to owners list
owners = append(owners, &clients.User{Login: owner.Value, NumContributions: 0, IsCodeOwner: true})
case codeowners.TeamOwner:
// if teamOwner expand and add to owners list (only accessible by org members with read:org token scope)
splitTeam := strings.Split(owner.Value, "/")
if len(splitTeam) == 2 {
users, response, err := handler.ghClient.Teams.ListTeamMembersBySlug(
handler.ctx,
splitTeam[0],
splitTeam[1],
&github.TeamListTeamMembersOptions{},
)
if err == nil && response.StatusCode == http.StatusOK {
for _, user := range users {
owners = append(owners, &clients.User{Login: user.GetLogin(), NumContributions: 0, IsCodeOwner: true})
}
}
}
}
}
}
// adding owners to contributor map and deduping
for _, owner := range owners {
if owner.Login == "" {
continue
}
value, ok := contributors[owner.Login]
if ok {
// if contributor exists already set IsCodeOwner to true
value.IsCodeOwner = true
contributors[owner.Login] = value
} else {
// otherwise add new contributor
contributors[owner.Login] = *owner
}
}
}
func (handler *contributorsHandler) getContributors(codeOwnerFile io.ReadCloser) ([]clients.User, error) {
if err := handler.setup(codeOwnerFile); err != nil {
return nil, fmt.Errorf("error during contributorsHandler.setup: %w", err)
}
return handler.contributors, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"time"
"github.com/ossf/scorecard/v5/clients"
)
func copyBoolPtr(src *bool, dest **bool) {
if src != nil {
*dest = new(bool)
**dest = *src
}
}
func copyStringPtr(src *string, dest **string) {
if src != nil {
*dest = new(string)
**dest = *src
}
}
func copyInt32Ptr(src *int32, dest **int32) {
if src != nil {
*dest = new(int32)
**dest = *src
}
}
func copyTimePtr(src *time.Time, dest **time.Time) {
if src != nil {
*dest = new(time.Time)
**dest = *src
}
}
func copyStringSlice(src []string, dest *[]string) {
*dest = make([]string, len(src))
copy(*dest, src)
}
func copyRepoAssociationPtr(src *clients.RepoAssociation, dest **clients.RepoAssociation) {
if src != nil {
*dest = new(clients.RepoAssociation)
**dest = *src
}
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/shurcooL/githubv4"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
const (
pullRequestsToAnalyze = 1
checksToAnalyze = 30
issuesToAnalyze = 30
issueCommentsToAnalyze = 30
reviewsToAnalyze = 30
labelsToAnalyze = 30
// https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api#node-limit
defaultPageLimit = 100
retryLimit = 3
)
//nolint:govet
type graphqlData struct {
Repository struct {
IsArchived githubv4.Boolean
Object struct {
Commit struct {
History struct {
Nodes []struct {
CommittedDate githubv4.DateTime
Message githubv4.String
Oid githubv4.GitObjectID
Author struct {
User struct {
Login githubv4.String
}
}
Committer struct {
Name *string
User struct {
Login *string
}
}
Signature struct {
IsValid bool
WasSignedByGitHub bool
}
AssociatedPullRequests struct {
Nodes []struct {
Repository struct {
Name githubv4.String
Owner struct {
Login githubv4.String
}
}
Author struct {
Login githubv4.String
ResourcePath githubv4.String
}
Number githubv4.Int
HeadRefOid githubv4.String
MergedAt githubv4.DateTime
Labels struct {
Nodes []struct {
Name githubv4.String
}
} `graphql:"labels(last: $labelsToAnalyze)"`
Reviews struct {
Nodes []struct {
State githubv4.String
Author struct {
Login githubv4.String
}
}
} `graphql:"reviews(last: $reviewsToAnalyze)"`
MergedBy struct {
Login githubv4.String
}
}
} `graphql:"associatedPullRequests(first: $pullRequestsToAnalyze)"`
}
PageInfo struct {
StartCursor githubv4.String
EndCursor githubv4.String
HasNextPage bool
}
} `graphql:"history(first: $commitsToAnalyze, after: $historyCursor)"`
} `graphql:"... on Commit"`
} `graphql:"object(expression: $commitExpression)"`
Issues struct {
Nodes []struct {
//nolint:revive,stylecheck // naming according to githubv4 convention.
Url *string
AuthorAssociation *string
Author struct {
Login githubv4.String
}
CreatedAt *time.Time
Comments struct {
Nodes []struct {
AuthorAssociation *string
CreatedAt *time.Time
Author struct {
Login githubv4.String
}
}
} `graphql:"comments(last: $issueCommentsToAnalyze)"`
}
} `graphql:"issues(first: $issuesToAnalyze, orderBy:{field:UPDATED_AT, direction:DESC})"`
} `graphql:"repository(owner: $owner, name: $name)"`
RateLimit struct {
Cost *int
}
}
type graphqlHandler struct {
client *githubv4.Client
data *graphqlData
setupOnce *sync.Once
ctx context.Context
errSetup error
repourl *Repo
commits []clients.Commit
issues []clients.Issue
archived bool
commitDepth int
}
func (handler *graphqlHandler) init(ctx context.Context, repourl *Repo, commitDepth int) {
handler.ctx = ctx
handler.repourl = repourl
handler.data = new(graphqlData)
handler.errSetup = nil
handler.setupOnce = new(sync.Once)
handler.commitDepth = commitDepth
handler.commits = nil
handler.issues = nil
}
func populateCommits(handler *graphqlHandler, vars map[string]interface{}) ([]clients.Commit, error) {
var commits []clients.Commit
commitsLeft, ok := vars["commitsToAnalyze"].(githubv4.Int)
if !ok {
return nil, sce.WithMessage(sce.ErrScorecardInternal, "unexpected type")
}
commitsRequested := min(defaultPageLimit, commitsLeft)
var retries int
for commitsLeft > 0 {
vars["commitsToAnalyze"] = commitsRequested
if err := handler.client.Query(handler.ctx, handler.data, vars); err != nil {
// 502 usually indicate timeouts, where we're requesting too much data
// so make our requests smaller and try again
if retries < retryLimit && strings.Contains(err.Error(), "502 Bad Gateway") {
retries++
commitsRequested /= 2
continue
}
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err))
}
vars["historyCursor"] = handler.data.Repository.Object.Commit.History.PageInfo.EndCursor
tmp, err := commitsFrom(handler.data, handler.repourl.owner, handler.repourl.repo)
if err != nil {
return nil, fmt.Errorf("failed to populate commits: %w", err)
}
commits = append(commits, tmp...)
commitsLeft -= commitsRequested
commitsRequested = min(commitsRequested, commitsLeft)
}
return commits, nil
}
func (handler *graphqlHandler) setup() error {
handler.setupOnce.Do(func() {
commitExpression := handler.repourl.commitExpression()
vars := map[string]interface{}{
"owner": githubv4.String(handler.repourl.owner),
"name": githubv4.String(handler.repourl.repo),
"pullRequestsToAnalyze": githubv4.Int(pullRequestsToAnalyze),
"issuesToAnalyze": githubv4.Int(issuesToAnalyze),
"issueCommentsToAnalyze": githubv4.Int(issueCommentsToAnalyze),
"reviewsToAnalyze": githubv4.Int(reviewsToAnalyze),
"labelsToAnalyze": githubv4.Int(labelsToAnalyze),
"commitsToAnalyze": githubv4.Int(handler.commitDepth),
"commitExpression": githubv4.String(commitExpression),
"historyCursor": (*githubv4.String)(nil),
}
handler.commits, handler.errSetup = populateCommits(handler, vars)
handler.issues = issuesFrom(handler.data)
handler.archived = bool(handler.data.Repository.IsArchived)
})
return handler.errSetup
}
func (handler *graphqlHandler) getCommits() ([]clients.Commit, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during graphqlHandler.setup: %w", err)
}
return handler.commits, nil
}
func (handler *graphqlHandler) getIssues() ([]clients.Issue, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return nil, fmt.Errorf("%w: ListIssues only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during graphqlHandler.setup: %w", err)
}
return handler.issues, nil
}
func (handler *graphqlHandler) isArchived() (bool, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return false, fmt.Errorf("%w: IsArchived only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
if err := handler.setup(); err != nil {
return false, fmt.Errorf("error during graphqlHandler.setup: %w", err)
}
return handler.archived, nil
}
func commitsFrom(data *graphqlData, repoOwner, repoName string) ([]clients.Commit, error) {
ret := make([]clients.Commit, 0)
for _, commit := range data.Repository.Object.Commit.History.Nodes {
var committer string
// Find the commit's committer.
if commit.Committer.User.Login != nil && *commit.Committer.User.Login != "" {
committer = *commit.Committer.User.Login
} else if commit.Committer.Name != nil &&
// Username "GitHub" may indicate the commit was committed by GitHub.
// We verify that the commit is signed by GitHub, because the name can be spoofed.
*commit.Committer.Name == "GitHub" &&
commit.Signature.IsValid &&
commit.Signature.WasSignedByGitHub {
committer = "github"
}
var associatedPR clients.PullRequest
for i := range commit.AssociatedPullRequests.Nodes {
pr := commit.AssociatedPullRequests.Nodes[i]
// NOTE: PR mergeCommit may not match commit.SHA in case repositories
// have `enableSquashCommit` disabled. So we accept any associatedPR
// to handle this case.
if string(pr.Repository.Owner.Login) != repoOwner ||
string(pr.Repository.Name) != repoName {
continue
}
// ResourcePath: e.g., for dependabot, "/apps/dependabot", or "/apps/renovate"
// Path that can be appended to "https://github.com" for a GitHub resource
openedByBot := strings.HasPrefix(string(pr.Author.ResourcePath), "/apps/")
associatedPR = clients.PullRequest{
Number: int(pr.Number),
HeadSHA: string(pr.HeadRefOid),
MergedAt: pr.MergedAt.Time,
Author: clients.User{
Login: string(pr.Author.Login),
IsBot: openedByBot,
},
MergedBy: clients.User{
Login: string(pr.MergedBy.Login),
},
}
for _, label := range pr.Labels.Nodes {
associatedPR.Labels = append(associatedPR.Labels, clients.Label{
Name: string(label.Name),
})
}
for _, review := range pr.Reviews.Nodes {
associatedPR.Reviews = append(associatedPR.Reviews, clients.Review{
State: string(review.State),
Author: &clients.User{
Login: string(review.Author.Login),
},
})
}
break
}
ret = append(ret, clients.Commit{
CommittedDate: commit.CommittedDate.Time,
Message: string(commit.Message),
SHA: string(commit.Oid),
Committer: clients.User{
Login: committer,
},
AssociatedMergeRequest: associatedPR,
})
}
return ret, nil
}
func issuesFrom(data *graphqlData) []clients.Issue {
var ret []clients.Issue
for _, issue := range data.Repository.Issues.Nodes {
var tmpIssue clients.Issue
copyStringPtr(issue.Url, &tmpIssue.URI)
copyRepoAssociationPtr(getRepoAssociation(issue.AuthorAssociation), &tmpIssue.AuthorAssociation)
copyTimePtr(issue.CreatedAt, &tmpIssue.CreatedAt)
if issue.Author.Login != "" {
tmpIssue.Author = &clients.User{
Login: string(issue.Author.Login),
}
}
for _, comment := range issue.Comments.Nodes {
var tmpComment clients.IssueComment
copyRepoAssociationPtr(getRepoAssociation(comment.AuthorAssociation), &tmpComment.AuthorAssociation)
copyTimePtr(comment.CreatedAt, &tmpComment.CreatedAt)
if comment.Author.Login != "" {
tmpComment.Author = &clients.User{
Login: string(comment.Author.Login),
}
}
tmpIssue.Comments = append(tmpIssue.Comments, tmpComment)
}
ret = append(ret, tmpIssue)
}
return ret
}
// getRepoAssociation returns the association of the user with the repository.
func getRepoAssociation(association *string) *clients.RepoAssociation {
if association == nil {
return nil
}
var repoAssociation clients.RepoAssociation
switch *association {
case "COLLABORATOR":
repoAssociation = clients.RepoAssociationCollaborator
case "CONTRIBUTOR":
repoAssociation = clients.RepoAssociationContributor
case "FIRST_TIMER":
repoAssociation = clients.RepoAssociationFirstTimer
case "FIRST_TIME_CONTRIBUTOR":
repoAssociation = clients.RepoAssociationFirstTimeContributor
case "MANNEQUIN":
repoAssociation = clients.RepoAssociationMannequin
case "MEMBER":
repoAssociation = clients.RepoAssociationMember
case "NONE":
repoAssociation = clients.RepoAssociationNone
case "OWNER":
repoAssociation = clients.RepoAssociationOwner
default:
return nil
}
return &repoAssociation
}
// Copyright 2023 OpenSSF 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 fnmatch
import (
"fmt"
"regexp"
"strings"
)
func Match(pattern, path string) (bool, error) {
r := convertToRegex(pattern)
m, err := regexp.MatchString(r, path)
if err != nil {
return false, fmt.Errorf("converted regex invalid: %w", err)
}
return m, nil
}
var specialRegexpChars = map[byte]struct{}{
'.': {},
'+': {},
'*': {},
'?': {},
'^': {},
'$': {},
'(': {},
')': {},
'[': {},
']': {},
'{': {},
'}': {},
'|': {},
'\\': {},
}
func convertToRegex(pattern string) string {
var regexPattern strings.Builder
regexPattern.WriteRune('^')
for len(pattern) > 0 {
matchLen := 1
switch {
case len(pattern) > 2 && pattern[:3] == "**/":
// Matches directories recursively
regexPattern.WriteString("(?:[^/]+/?)+")
matchLen = 3
case len(pattern) > 1 && pattern[:2] == "**":
// Matches files expansively.
regexPattern.WriteString("[^/]+")
matchLen = 2
case len(pattern) > 1 && pattern[:1] == "\\":
writePotentialRegexpChar(®exPattern, pattern[1])
matchLen = 2
default:
switch pattern[0] {
case '*':
// Equivalent to ".*"" in regexp, but GitHub uses the File::FNM_PATHNAME flag for the File.fnmatch syntax
// the * wildcard does not match directory separators (/).
regexPattern.WriteString("[^/]*")
case '?':
// Matches any one character. Equivalent to ".{1}" in regexp, see FNM_PATHNAME note above.
regexPattern.WriteString("[^/]{1}")
case '[', ']':
// "[" and "]" represent character sets in fnmatch too
regexPattern.WriteByte(pattern[0])
default:
writePotentialRegexpChar(®exPattern, pattern[0])
}
}
pattern = pattern[matchLen:]
}
regexPattern.WriteRune('$')
return regexPattern.String()
}
// Characters with special meaning in regexp may need escaped.
func writePotentialRegexpChar(sb *strings.Builder, b byte) {
if _, ok := specialRegexpChars[b]; ok {
sb.WriteRune('\\')
}
sb.WriteByte(b)
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"path"
"sync"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
)
type languagesHandler struct {
ghclient *github.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
languages []clients.Language
}
func (handler *languagesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.languages = nil
}
// TODO: Can add support to parse the raw response JSON and mark languages that are not in
// our defined Language consts in clients/languages.go as "not supported languages".
func (handler *languagesHandler) setup() error {
handler.once.Do(func() {
client := handler.ghclient
reqURL := path.Join("repos", handler.repourl.owner, handler.repourl.repo, "languages")
req, err := client.NewRequest("GET", reqURL, nil)
if err != nil {
handler.errSetup = fmt.Errorf("request for repo languages failed with %w", err)
return
}
bodyJSON := map[clients.LanguageName]int{}
// The client.repoClient.Do API writes the response body to var bodyJSON,
// so we can ignore the first returned variable (the entire http response object)
// since we only need the response body here.
_, err = client.Do(handler.ctx, req, &bodyJSON)
if err != nil {
handler.errSetup = fmt.Errorf("response for repo languages failed with %w", err)
return
}
// Parse the raw JSON to an array of languages.
for k, v := range bodyJSON {
// TODO: once the const defined in clients/languages.go becomes a complete list of langs supported,
// add support here so that for not supported langs, it emits an "not-supported" error and break the parse.
// Currently, we are parsing all the JSON-returned langs into the result since the const is incomplete.
handler.languages = append(handler.languages,
clients.Language{
Name: k,
NumLines: v,
},
)
}
handler.errSetup = nil
})
return handler.errSetup
}
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
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"net/http"
"path"
"sync"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
)
type licensesHandler struct {
ghclient *github.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
licenses []clients.License
}
func (handler *licensesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.licenses = nil
}
// TODO: Can add support to parse the raw response JSON and mark licenses that are not in
// our defined License consts in clients/licenses.go as "not supported licenses".
func (handler *licensesHandler) setup() error {
handler.once.Do(func() {
client := handler.ghclient
// defined at docs.github.com/en/rest/licenses#get-the-license-for-a-repository
reqURL := path.Join("repos", handler.repourl.owner, handler.repourl.repo, "license")
req, err := client.NewRequest("GET", reqURL, nil)
if err != nil {
handler.errSetup = fmt.Errorf("request for repo license failed with %w", err)
return
}
bodyJSON := github.RepositoryLicense{}
// The client.repoClient.Do API writes the response body to var bodyJSON,
// so we can ignore the first returned variable (the entire http response object)
// since we only need the response body here.
resp, derr := client.Do(handler.ctx, req, &bodyJSON)
switch resp.StatusCode {
// Handle 400 error, perhaps the API changed.
case http.StatusBadRequest:
handler.errSetup = fmt.Errorf("bad request for repo license code %d, %w", resp.StatusCode, derr)
return
// Handle 404 error, appears that the repo has no license,
// just return no need to log or error off.
case http.StatusNotFound:
return
}
if derr != nil {
handler.errSetup = fmt.Errorf("response for repo license failed with %w", derr)
return
}
// TODO: github.RepositoryLicense{} only supports one license per repo
// should that change to an array of licenses, the change would
// be here to iterate over any such range.
handler.licenses = append(handler.licenses, clients.License{
Key: bodyJSON.GetLicense().GetKey(),
Name: bodyJSON.GetLicense().GetName(),
SPDXId: bodyJSON.GetLicense().GetSPDXID(),
Path: bodyJSON.GetName(),
Type: bodyJSON.GetType(),
Size: bodyJSON.GetSize(),
},
)
handler.errSetup = nil
})
return handler.errSetup
}
func (handler *licensesHandler) listLicenses() ([]clients.License, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during licensesHandler.setup: %w", err)
}
return handler.licenses, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type releasesHandler struct {
client *github.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
releases []clients.Release
}
func (handler *releasesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.releases = nil
}
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.client.Repositories.ListReleases(
handler.ctx, handler.repourl.owner, handler.repourl.repo, &github.ListOptions{})
if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err))
}
handler.releases = releasesFrom(releases)
})
return handler.errSetup
}
func (handler *releasesHandler) getReleases() ([]clients.Release, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during graphqlHandler.setup: %w", err)
}
return handler.releases, nil
}
func releasesFrom(data []*github.RepositoryRelease) []clients.Release {
var releases []clients.Release
for _, r := range data {
release := clients.Release{
TagName: r.GetTagName(),
URL: r.GetURL(),
TargetCommitish: r.GetTargetCommitish(),
}
for _, a := range r.Assets {
release.Assets = append(release.Assets, clients.ReleaseAsset{
Name: a.GetName(),
URL: r.GetHTMLURL(),
})
}
releases = append(releases, release)
}
return releases
}
// Copyright 2020 OpenSSF 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 githubrepo
import (
"fmt"
"net/url"
"os"
"strings"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type Repo struct {
host, owner, repo, defaultBranch, commitSHA string
metadata []string
}
// Parses input string into repoURL struct.
// Accepts "owner/repo" or "github.com/owner/repo".
func (r *Repo) parse(input string) error {
var t string
const two = 2
const three = 3
c := strings.Split(input, "/")
switch l := len(c); {
// This will takes care for repo/owner format.
// By default it will use github.com
case l == two:
githubHost, isGhHost := os.LookupEnv("GH_HOST")
if !isGhHost {
githubHost = "github.com"
}
t = githubHost + "/" + c[0] + "/" + c[1]
case l >= three:
t = input
}
// Allow skipping scheme for ease-of-use, default to https.
if !strings.Contains(t, "://") {
t = "https://" + t
}
u, e := url.Parse(t)
if e != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", e))
}
const splitLen = 2
split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen)
if len(split) != splitLen {
return sce.WithMessage(sce.ErrInvalidURL, fmt.Sprintf("%v. Expected full repository url", input))
}
r.host, r.owner, r.repo = u.Host, split[0], split[1]
return nil
}
// URI implements Repo.URI().
func (r *Repo) URI() string {
return fmt.Sprintf("%s/%s/%s", r.host, r.owner, r.repo)
}
func (r *Repo) Host() string {
return r.host
}
// String implements Repo.String.
func (r *Repo) String() string {
return fmt.Sprintf("%s-%s-%s", r.host, r.owner, r.repo)
}
// IsValid implements Repo.IsValid.
func (r *Repo) IsValid() error {
githubHost := os.Getenv("GH_HOST")
switch r.host {
case "github.com":
case githubHost:
default:
return sce.WithMessage(sce.ErrUnsupportedHost, r.host)
}
if strings.TrimSpace(r.owner) == "" || strings.TrimSpace(r.repo) == "" {
return sce.WithMessage(sce.ErrInvalidURL,
fmt.Sprintf("%v. Expected the full repository url", r.URI()))
}
return nil
}
func (r *Repo) AppendMetadata(metadata ...string) {
r.metadata = append(r.metadata, metadata...)
}
// Metadata implements Repo.Metadata.
func (r *Repo) Metadata() []string {
return r.metadata
}
func (r *Repo) commitExpression() string {
if strings.EqualFold(r.commitSHA, clients.HeadSHA) {
// TODO(#575): Confirm that this works as expected.
return fmt.Sprintf("heads/%s", r.defaultBranch)
}
return r.commitSHA
}
// MakeGithubRepo takes input of form "owner/repo" or "github.com/owner/repo"
// and returns an implementation of clients.Repo interface.
func MakeGithubRepo(input string) (clients.Repo, error) {
var repo Repo
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 in IsValid: %w", err)
}
return &repo, nil
}
// Path() implements RepoClient.Path.
func (r *Repo) Path() string {
return fmt.Sprintf("%s/%s", r.owner, r.repo)
}
// Copyright 2020 OpenSSF 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 roundtripper
import (
"fmt"
"net/http"
"go.opencensus.io/plugin/ochttp"
opencensusstats "go.opencensus.io/stats"
"go.opencensus.io/tag"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/stats"
)
const fromCacheHeader = "X-From-Cache"
// MakeCensusTransport wraps input Roundtripper with monitoring logic.
func MakeCensusTransport(innerTransport http.RoundTripper) http.RoundTripper {
return &ochttp.Transport{
Base: &censusTransport{
innerTransport: innerTransport,
},
}
}
// censusTransport is a monitoring aware http.Transport.
type censusTransport struct {
innerTransport http.RoundTripper
}
// Roundtrip handles context update and measurement recording.
func (ct *censusTransport) RoundTrip(r *http.Request) (*http.Response, error) {
ctx, err := tag.New(r.Context(), tag.Upsert(stats.RequestTag, "requested"))
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("tag.New: %v", err))
}
r = r.WithContext(ctx)
resp, err := ct.innerTransport.RoundTrip(r)
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("innerTransport.RoundTrip: %v", err))
}
if resp.Header.Get(fromCacheHeader) != "" {
ctx, err = tag.New(ctx, tag.Upsert(stats.RequestTag, fromCacheHeader))
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("tag.New: %v", err))
}
}
opencensusstats.Record(ctx, stats.HTTPRequests.M(1))
return resp, nil
}
// Copyright 2020 OpenSSF 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 roundtripper
import (
"fmt"
"net/http"
"strconv"
"time"
"go.opencensus.io/stats"
"go.opencensus.io/tag"
githubstats "github.com/ossf/scorecard/v5/clients/githubrepo/stats"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/log"
)
// MakeRateLimitedTransport returns a RoundTripper which rate limits GitHub requests.
func MakeRateLimitedTransport(innerTransport http.RoundTripper, logger *log.Logger) http.RoundTripper {
return &rateLimitTransport{
logger: logger,
innerTransport: innerTransport,
}
}
// rateLimitTransport is a rate-limit aware http.Transport for GitHub.
type rateLimitTransport struct {
logger *log.Logger
innerTransport http.RoundTripper
}
// RoundTrip handles caching and rate-limiting of responses from GitHub.
func (gh *rateLimitTransport) RoundTrip(r *http.Request) (*http.Response, error) {
resp, err := gh.innerTransport.RoundTrip(r)
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("innerTransport.RoundTrip: %v", err))
}
retryValue := resp.Header.Get("Retry-After")
if retryAfter, err := strconv.Atoi(retryValue); err == nil { // if NO error
stats.Record(r.Context(), githubstats.RetryAfter.M(int64(retryAfter)))
duration := time.Duration(retryAfter) * time.Second
gh.logger.Info(fmt.Sprintf("Retry-After header set. Waiting %s to retry...", duration))
time.Sleep(duration)
gh.logger.Info("Retry-After header set. Retrying...")
return gh.RoundTrip(r)
}
rateLimit := resp.Header.Get("X-RateLimit-Remaining")
remaining, err := strconv.Atoi(rateLimit)
if err != nil {
//nolint:nilerr // just an error in metadata, response may still be useful?
return resp, nil
}
ctx, err := tag.New(r.Context(), tag.Upsert(githubstats.ResourceType, resp.Header.Get("X-RateLimit-Resource")))
if err != nil {
return nil, fmt.Errorf("error updating context: %w", err)
}
stats.Record(ctx, githubstats.RemainingTokens.M(int64(remaining)))
if remaining <= 0 {
reset, err := strconv.Atoi(resp.Header.Get("X-RateLimit-Reset"))
if err != nil {
//nolint:nilerr // just an error in metadata, response may still be useful?
return resp, nil
}
duration := time.Until(time.Unix(int64(reset), 0))
// TODO(log): Previously Warn. Consider logging an error here.
gh.logger.Info(fmt.Sprintf("Rate limit exceeded. Waiting %s to retry...", duration))
// Retry
time.Sleep(duration)
// TODO(log): Previously Warn. Consider logging an error here.
gh.logger.Info("Rate limit exceeded. Retrying...")
return gh.RoundTrip(r)
}
return resp, nil
}
// Copyright 2020 OpenSSF 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 roundtripper has implementations of http.RoundTripper useful to clients.RepoClient.
package roundtripper
import (
"context"
"errors"
"net/http"
"os"
"strconv"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper/tokens"
"github.com/ossf/scorecard/v5/log"
)
const (
// githubAppKeyPath is the path to file for GitHub App key.
githubAppKeyPath = "GITHUB_APP_KEY_PATH"
// githubAppID is the app ID for the GitHub App.
githubAppID = "GITHUB_APP_ID"
// githubAppInstallationID is the installation ID for the GitHub App.
githubAppInstallationID = "GITHUB_APP_INSTALLATION_ID"
)
var errGithubCredentials = errors.New("an error occurred while getting GitHub credentials")
// NewTransport returns a configured http.Transport for use with GitHub.
func NewTransport(ctx context.Context, logger *log.Logger) http.RoundTripper {
transport := http.DefaultTransport
//nolint:nestif
if tokenAccessor := tokens.MakeTokenAccessor(); tokenAccessor != nil {
// Use GitHub PAT
transport = makeGitHubTransport(transport, tokenAccessor)
} else if keyPath := os.Getenv(githubAppKeyPath); keyPath != "" { // Also try a GITHUB_APP
appID, err := strconv.Atoi(os.Getenv(githubAppID))
if err != nil {
logger.Error(err, "getting GitHub application ID from environment")
}
installationID, err := strconv.Atoi(os.Getenv(githubAppInstallationID))
if err != nil {
logger.Error(err, "getting GitHub application installation ID")
}
transport, err = ghinstallation.NewKeyFromFile(transport, int64(appID), int64(installationID), keyPath)
if err != nil {
logger.Error(err, "getting a private key from file")
}
} else {
// TODO(log): Improve error message
//nolint:lll
logger.Error(errGithubCredentials, "GitHub token env var is not set. Please read https://github.com/ossf/scorecard#authentication")
}
return MakeCensusTransport(MakeRateLimitedTransport(transport, logger))
}
// Copyright 2021 OpenSSF 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 tokens defines interface to access GitHub PATs.
package tokens
import (
"log"
"os"
"strings"
)
// githubAuthServer is the RPC URL for the token server.
const githubAuthServer = "GITHUB_AUTH_SERVER"
// env variables from which GitHub auth tokens are read, in order of precedence.
var githubAuthTokenEnvVars = []string{"GITHUB_AUTH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "GH_AUTH_TOKEN"}
// TokenAccessor interface defines a `retrieve-once` data structure.
// Implementations of this interface must be thread-safe.
type TokenAccessor interface {
Next() (uint64, string)
Release(uint64)
}
var logDuplicateTokenWarning = func(firstName string, clashingName string) {
var stringBuilder strings.Builder
stringBuilder.WriteString("Warning: PATs stored in env variables ")
stringBuilder.WriteString(firstName)
stringBuilder.WriteString(" and ")
stringBuilder.WriteString(clashingName)
stringBuilder.WriteString(" differ. Scorecard will use the former.")
log.Println(stringBuilder.String())
}
func readGitHubTokens() (string, bool) {
var firstName, firstToken string
for _, name := range githubAuthTokenEnvVars {
if token, exists := os.LookupEnv(name); exists && token != "" {
if firstName == "" {
firstName = name
firstToken = token
} else if token != firstToken {
logDuplicateTokenWarning(firstName, name)
}
}
}
if firstName == "" {
return "", false
} else {
return firstToken, true
}
}
// MakeTokenAccessor is a factory function of TokenAccessor.
func MakeTokenAccessor() TokenAccessor {
if value, exists := readGitHubTokens(); exists {
return makeRoundRobinAccessor(strings.Split(value, ","))
}
if value, exists := os.LookupEnv(githubAuthServer); exists && value != "" {
return makeRPCAccessor(value)
}
return nil
}
// Copyright 2021 OpenSSF 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 tokens
import (
"sync/atomic"
"time"
)
const expiryTimeInSec = 30
// roundRobinAccessor implements TokenAccessor.
type roundRobinAccessor struct {
accessTokens []string
accessState []int64
counter uint64
}
// Next implements TokenAccessor.Next.
func (tokens *roundRobinAccessor) Next() (uint64, string) {
c := atomic.AddUint64(&tokens.counter, 1)
l := len(tokens.accessTokens)
index := c % uint64(l)
// If selected accessToken is unavailable, wait.
for !atomic.CompareAndSwapInt64(&tokens.accessState[index], 0, time.Now().Unix()) {
currVal := atomic.LoadInt64(&tokens.accessState[index])
expired := time.Now().After(time.Unix(currVal, 0).Add(expiryTimeInSec * time.Second))
if !expired {
continue
}
if atomic.CompareAndSwapInt64(&tokens.accessState[index], currVal, time.Now().Unix()) {
break
}
}
return index, tokens.accessTokens[index]
}
// Release implements TokenAccessor.Release.
func (tokens *roundRobinAccessor) Release(id uint64) {
atomic.SwapInt64(&tokens.accessState[id], 0)
}
func makeRoundRobinAccessor(accessTokens []string) TokenAccessor {
return &roundRobinAccessor{
accessTokens: accessTokens,
accessState: make([]int64, len(accessTokens)),
}
}
// Copyright 2021 OpenSSF 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 tokens
// Token is used for GitHub token server RPC request/response.
type Token struct {
Value string
ID uint64
}
// TokenOverRPC is an RPC handler implementation for Golang RPCs.
type TokenOverRPC struct {
client TokenAccessor
}
// Next requests for the next available GitHub token.
// Server blocks the call until a token becomes available.
func (accessor *TokenOverRPC) Next(args struct{}, token *Token) error {
id, val := accessor.client.Next()
*token = Token{
ID: id,
Value: val,
}
return nil
}
// Release inserts the token at `index` back into the token pool to be used by another client.
func (accessor *TokenOverRPC) Release(id uint64, reply *struct{}) error {
accessor.client.Release(id)
return nil
}
// NewTokenOverRPC creates a new instance of TokenOverRPC.
func NewTokenOverRPC(client TokenAccessor) *TokenOverRPC {
return &TokenOverRPC{
client: client,
}
}
// Copyright 2021 OpenSSF 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 tokens
import (
"log"
"net/rpc"
)
// rpcAccessor implements TokenAccessor.
type rpcAccessor struct {
client *rpc.Client
}
// Next implements TokenAccessor.Next.
func (accessor *rpcAccessor) Next() (uint64, string) {
var token Token
if err := accessor.client.Call("TokenOverRPC.Next", struct{}{}, &token); err != nil {
log.Printf("error during RPC call Next: %v", err)
return 0, ""
}
return token.ID, token.Value
}
// Release implements TokenAccessor.Release.
func (accessor *rpcAccessor) Release(id uint64) {
if err := accessor.client.Call("TokenOverRPC.Release", id, &struct{}{}); err != nil {
log.Printf("error during RPC call Release: %v", err)
}
}
func makeRPCAccessor(serverURL string) TokenAccessor {
client, err := rpc.DialHTTP("tcp", serverURL)
if err != nil {
panic(err)
}
return &rpcAccessor{
client: client,
}
}
// Copyright 2021 OpenSSF 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 roundtripper
import (
"fmt"
"net/http"
"go.opencensus.io/tag"
"github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper/tokens"
githubstats "github.com/ossf/scorecard/v5/clients/githubrepo/stats"
)
// makeGitHubTransport wraps input RoundTripper with GitHub authorization logic.
func makeGitHubTransport(innerTransport http.RoundTripper, accessor tokens.TokenAccessor) http.RoundTripper {
return &githubTransport{
innerTransport: innerTransport,
tokens: accessor,
}
}
// githubTransport handles authorization using GitHub personal access tokens (PATs) during HTTP requests.
type githubTransport struct {
innerTransport http.RoundTripper
tokens tokens.TokenAccessor
}
func (gt *githubTransport) RoundTrip(r *http.Request) (*http.Response, error) {
id, token := gt.tokens.Next()
defer gt.tokens.Release(id)
ctx, err := tag.New(r.Context(), tag.Upsert(githubstats.TokenIndex, fmt.Sprint(id)))
if err != nil {
return nil, fmt.Errorf("error updating context: %w", err)
}
*r = *r.WithContext(ctx)
r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
resp, err := gt.innerTransport.RoundTrip(r)
if err != nil {
return nil, fmt.Errorf("error in HTTP: %w", err)
}
return resp, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
)
var errEmptyQuery = errors.New("search query is empty")
type searchHandler struct {
ghClient *github.Client
ctx context.Context
repourl *Repo
}
func (handler *searchHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
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)
}
resp, _, err := handler.ghClient.Search.Code(handler.ctx, query, &github.SearchOptions{})
if err != nil {
return clients.SearchResponse{}, fmt.Errorf("Search.Code: %w", err)
}
return searchResponseFrom(resp), 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(
// The fuzzing check searches for GitHub URI, e.g. `github.com/org/repo`. The forward slash is one special character
// that should be replaced with a space.
// See https://docs.github.com/en/search-github/searching-on-github/searching-code#considerations-for-code-search
// for reference.
fmt.Sprintf("%s repo:%s/%s",
strings.ReplaceAll(request.Query, "/", " "),
handler.repourl.owner, handler.repourl.repo)); 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
}
func searchResponseFrom(resp *github.CodeSearchResult) clients.SearchResponse {
var ret clients.SearchResponse
ret.Hits = resp.GetTotal()
for _, result := range resp.CodeResults {
ret.Results = append(ret.Results, clients.SearchResult{
Path: result.GetPath(),
})
}
return ret
}
// Copyright 2022 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
)
type searchCommitsHandler struct {
ghClient *github.Client
ctx context.Context
repourl *Repo
}
func (handler *searchCommitsHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
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.buildQuery: %w", err)
}
resp, _, err := handler.ghClient.Search.Commits(handler.ctx,
query,
&github.SearchOptions{ListOptions: github.ListOptions{PerPage: 100}})
if err != nil {
return nil, fmt.Errorf("Search.Code: %w", err)
}
return searchCommitsResponseFrom(resp), 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("repo:%s/%s author:%s",
handler.repourl.owner, handler.repourl.repo,
request.Author)); err != nil {
return "", fmt.Errorf("WriteString: %w", err)
}
return queryBuilder.String(), nil
}
func searchCommitsResponseFrom(resp *github.CommitsSearchResult) []clients.Commit {
var ret []clients.Commit
for _, result := range resp.Commits {
ret = append(ret, clients.Commit{
Committer: clients.User{ID: *result.Author.ID},
})
}
return ret
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type statusesHandler struct {
client *github.Client
ctx context.Context
repourl *Repo
}
func (handler *statusesHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
}
func (handler *statusesHandler) listStatuses(ref string) ([]clients.Status, error) {
statuses, _, err := handler.client.Repositories.ListStatuses(
handler.ctx, handler.repourl.owner, handler.repourl.repo, ref, &github.ListOptions{})
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("ListStatuses: %v", err))
}
return statusesFrom(statuses), nil
}
func statusesFrom(data []*github.RepoStatus) []clients.Status {
var statuses []clients.Status
for _, status := range data {
statuses = append(statuses, clients.Status{
State: status.GetState(),
Context: status.GetContext(),
URL: status.GetURL(),
TargetURL: status.GetTargetURL(),
})
}
return statuses
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
const (
repoDir = "repo*"
repoFilename = "githubrepo*.tar.gz"
orgGithubRepo = ".github"
)
var (
errTarballNotFound = errors.New("tarball not found")
errTarballCorrupted = errors.New("corrupted tarball")
errZipSlip = errors.New("ZipSlip path detected")
)
func extractAndValidateArchivePath(path, dest string) (string, error) {
const splitLength = 2
// The tarball will have a top-level directory which contains all the repository files.
// Discard the directory and only keep the actual files.
names := strings.SplitN(path, "/", splitLength)
if len(names) < splitLength {
return dest, nil
}
if names[1] == "" {
return dest, nil
}
// Check for ZipSlip: https://snyk.io/research/zip-slip-vulnerability
cleanpath := filepath.Join(dest, names[1])
if !strings.HasPrefix(cleanpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return "", fmt.Errorf("%w: %s", errZipSlip, names[1])
}
return cleanpath, nil
}
type tarballHandler struct {
errSetup error
once *sync.Once
ctx context.Context
repo *github.Repository
httpClient *http.Client
commitSHA string
tempDir string
tempTarFile string
files []string
}
func (handler *tarballHandler) init(ctx context.Context, repo *github.Repository, commitSHA string) {
handler.errSetup = nil
handler.once = new(sync.Once)
handler.ctx = ctx
handler.repo = repo
handler.commitSHA = commitSHA
}
func (handler *tarballHandler) setup() error {
handler.once.Do(func() {
// Cleanup any previous state.
if err := handler.cleanup(); err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
// Setup temp dir/files and download repo tarball.
if err := handler.getTarball(); errors.Is(err, errTarballNotFound) {
// don't warn for "someorg/.github" repos
// https://github.com/ossf/scorecard/issues/3076
if handler.repo.GetName() == orgGithubRepo {
return
}
log.Printf("unable to get tarball %v. Skipping...", err)
return
} else if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
// Extract file names and content from tarball.
if err := handler.extractTarball(); errors.Is(err, errTarballCorrupted) {
log.Printf("unable to extract tarball %v. Skipping...", err)
} else if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
})
return handler.errSetup
}
func (handler *tarballHandler) getTarball() error {
url := handler.repo.GetArchiveURL()
url = strings.Replace(url, "{archive_format}", "tarball/", 1)
if strings.EqualFold(handler.commitSHA, clients.HeadSHA) {
url = strings.Replace(url, "{/ref}", "", 1)
} else {
url = strings.Replace(url, "{/ref}", handler.commitSHA, 1)
}
req, err := http.NewRequestWithContext(handler.ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("http.NewRequestWithContext: %w", err)
}
resp, err := handler.httpClient.Do(req)
if err != nil {
return fmt.Errorf("handler.httpClient.Do: %w", err)
}
defer resp.Body.Close()
// Handle 400/404 errors
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest:
return fmt.Errorf("%w: %s", errTarballNotFound, url)
}
// Create a temp file. This automatically appends a random number to the name.
tempDir, err := os.MkdirTemp("", repoDir)
if err != nil {
return fmt.Errorf("os.MkdirTemp: %w", err)
}
repoFile, err := os.CreateTemp(tempDir, repoFilename)
if err != nil {
return fmt.Errorf("os.CreateTemp: %w", err)
}
defer repoFile.Close()
if _, err := io.Copy(repoFile, resp.Body); err != nil {
// This can happen if the incoming tarball is corrupted/server gateway times out.
return fmt.Errorf("%w io.Copy: %w", errTarballNotFound, err)
}
handler.tempDir = tempDir
handler.tempTarFile = repoFile.Name()
return nil
}
//nolint:gocognit
func (handler *tarballHandler) extractTarball() error {
in, err := os.OpenFile(handler.tempTarFile, os.O_RDONLY, 0o644)
if err != nil {
return fmt.Errorf("os.OpenFile: %w", err)
}
gz, err := gzip.NewReader(in)
if err != nil {
return fmt.Errorf("%w: gzip.NewReader %v %w", errTarballCorrupted, handler.tempTarFile, err)
}
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("%w tarReader.Next: %w", errTarballCorrupted, err)
}
switch header.Typeflag {
case tar.TypeDir:
dirpath, err := extractAndValidateArchivePath(header.Name, handler.tempDir)
if err != nil {
return err
}
if dirpath == filepath.Clean(handler.tempDir) {
continue
}
if err := os.Mkdir(dirpath, 0o755); err != nil {
return fmt.Errorf("error during os.Mkdir: %w", err)
}
case tar.TypeReg:
if header.Size <= 0 {
continue
}
filenamepath, err := extractAndValidateArchivePath(header.Name, handler.tempDir)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Dir(filenamepath)); os.IsNotExist(err) {
if err := os.Mkdir(filepath.Dir(filenamepath), 0o755); err != nil {
return fmt.Errorf("os.Mkdir: %w", err)
}
}
outFile, err := os.Create(filenamepath)
if err != nil {
return fmt.Errorf("os.Create: %w", err)
}
//nolint:gosec
// Potential for DoS vulnerability via decompression bomb.
// Since such an attack will only impact a single shard, ignoring this for now.
if _, err := io.Copy(outFile, tr); err != nil {
return fmt.Errorf("%w io.Copy: %w", errTarballCorrupted, err)
}
outFile.Close()
handler.files = append(handler.files,
strings.TrimPrefix(filenamepath, filepath.Clean(handler.tempDir)+string(os.PathSeparator)))
case tar.TypeXGlobalHeader, tar.TypeSymlink:
continue
default:
log.Printf("Unknown file type %s: '%s'", header.Name, string(header.Typeflag))
continue
}
}
return nil
}
func (handler *tarballHandler) listFiles(predicate func(string) (bool, error)) ([]string, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during tarballHandler.setup: %w", err)
}
ret := make([]string, 0)
for _, file := range handler.files {
matches, err := predicate(file)
if err != nil {
return nil, err
}
if matches {
ret = append(ret, file)
}
}
return ret, nil
}
func (handler *tarballHandler) getLocalPath() (string, error) {
if err := handler.setup(); err != nil {
return "", fmt.Errorf("error during tarballHandler.setup: %w", err)
}
absTempDir, err := filepath.Abs(handler.tempDir)
if err != nil {
return "", fmt.Errorf("error during filepath.Abs: %w", err)
}
return absTempDir, nil
}
func (handler *tarballHandler) getFile(filename string) (*os.File, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during tarballHandler.setup: %w", err)
}
f, err := os.Open(filepath.Join(handler.tempDir, filename))
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
func (handler *tarballHandler) cleanup() error {
if err := os.RemoveAll(handler.tempDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("os.Remove: %w", err)
}
// Remove old files so we don't iterate through them.
handler.files = nil
return nil
}
// Copyright 2022 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"sync"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
)
type webhookHandler struct {
ghClient *github.Client
once *sync.Once
ctx context.Context
errSetup error
repourl *Repo
webhook []clients.Webhook
}
func (handler *webhookHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.webhook = nil
}
func (handler *webhookHandler) setup() error {
handler.once.Do(func() {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
handler.errSetup = fmt.Errorf("%w: ListWebHooks only supported for HEAD queries", clients.ErrUnsupportedFeature)
return
}
hooks, _, err := handler.ghClient.Repositories.ListHooks(
handler.ctx, handler.repourl.owner, handler.repourl.repo, &github.ListOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("error during ListHooks: %w", err)
return
}
for _, hook := range hooks {
repoHook := clients.Webhook{
ID: hook.GetID(),
UsesAuthSecret: getAuthSecret(hook.Config),
}
handler.webhook = append(handler.webhook, repoHook)
}
handler.errSetup = nil
})
return handler.errSetup
}
func getAuthSecret(config map[string]interface{}) bool {
if val, ok := config["secret"]; ok {
if val != nil {
return true
}
}
return false
}
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.webhook, nil
}
// Copyright 2021 OpenSSF 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 githubrepo
import (
"context"
"fmt"
"strings"
"github.com/google/go-github/v53/github"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type workflowsHandler struct {
client *github.Client
ctx context.Context
repourl *Repo
}
func (handler *workflowsHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
}
func (handler *workflowsHandler) listSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
if !strings.EqualFold(handler.repourl.commitSHA, clients.HeadSHA) {
return nil, fmt.Errorf(
"%w: ListWorkflowRunsByFileName only supported for HEAD queries", clients.ErrUnsupportedFeature)
}
workflowRuns, _, err := handler.client.Actions.ListWorkflowRunsByFileName(
handler.ctx, handler.repourl.owner, handler.repourl.repo, filename, &github.ListWorkflowRunsOptions{
Status: "success",
})
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("ListWorkflowRunsByFileName: %v", err))
}
return workflowsRunsFrom(workflowRuns), nil
}
func workflowsRunsFrom(data *github.WorkflowRuns) []clients.WorkflowRun {
var workflowRuns []clients.WorkflowRun
for _, workflowRun := range data.WorkflowRuns {
workflowRuns = append(workflowRuns, clients.WorkflowRun{
URL: workflowRun.GetURL(),
HeadSHA: workflowRun.HeadSHA,
})
}
return workflowRuns
}
// Copyright 2022 OpenSSF 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"
"net/http"
"strings"
"sync"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type branchesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
defaultBranchRef *clients.BranchRef
queryProject fnProject
queryBranch fnQueryBranch
getProtectedBranch fnProtectedBranch
getProjectChecks fnListProjectStatusChecks
getApprovalConfiguration fnGetApprovalConfiguration
}
func (handler *branchesHandler) init(repourl *Repo) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.queryProject = handler.glClient.Projects.GetProject
handler.queryBranch = handler.glClient.Branches.GetBranch
handler.getProtectedBranch = handler.glClient.ProtectedBranches.GetProtectedBranch
handler.getProjectChecks = handler.glClient.ExternalStatusChecks.ListProjectStatusChecks
handler.getApprovalConfiguration = handler.glClient.Projects.GetApprovalConfiguration
}
type (
fnProject func(pid interface{}, opt *gitlab.GetProjectOptions,
options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
fnQueryBranch func(pid interface{}, branch string,
options ...gitlab.RequestOptionFunc) (*gitlab.Branch, *gitlab.Response, error)
fnProtectedBranch func(pid interface{}, branch string,
options ...gitlab.RequestOptionFunc) (*gitlab.ProtectedBranch, *gitlab.Response, error)
fnListProjectStatusChecks func(pid interface{}, opt *gitlab.ListOptions,
options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectStatusCheck, *gitlab.Response, error)
fnGetApprovalConfiguration func(pid interface{},
options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovals, *gitlab.Response, error)
)
//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.queryProject(handler.repourl.projectID, &gitlab.GetProjectOptions{})
if err != nil {
handler.errSetup = fmt.Errorf("request for project failed with error %w", err)
return
}
branch, _, err := handler.queryBranch(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.getProtectedBranch(
handler.repourl.projectID, branch.Name)
if err != nil && resp.StatusCode != http.StatusForbidden {
handler.errSetup = fmt.Errorf("request for protected branch failed with error %w", err)
return
} else if resp.StatusCode == http.StatusForbidden {
handler.errSetup = fmt.Errorf("incorrect permissions to fully check branch protection %w", err)
return
}
projectStatusChecks, resp, err := handler.getProjectChecks(handler.repourl.projectID, &gitlab.ListOptions{})
if resp.StatusCode != http.StatusOK || err != nil {
handler.errSetup = fmt.Errorf("request for external status checks failed with error %w", err)
}
projectApprovalRule, resp, err := handler.getApprovalConfiguration(handler.repourl.projectID)
if err != nil && resp.StatusCode != http.StatusNotFound {
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) {
if strings.Contains(branch, "/-/commit/") {
// Gitlab's release commitish contains commit and is not easily tied to specific branch
p, b := true, ""
ret := &clients.BranchRef{
Name: &b,
Protected: &p,
}
return ret, nil
}
bran, _, err := handler.queryBranch(handler.repourl.projectID, branch)
if err != nil {
return nil, fmt.Errorf("error getting branch in branchesHandler.getBranch: %w", err)
}
if bran.Protected {
protectedBranch, _, err := handler.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.getProjectChecks(
handler.repourl.projectID, &gitlab.ListOptions{})
if err != nil && resp.StatusCode != http.StatusNotFound {
return nil, fmt.Errorf("request for external status checks failed with error %w", err)
}
projectApprovalRule, resp, err := handler.getApprovalConfiguration(handler.repourl.projectID)
if err != nil && resp.StatusCode != http.StatusNotFound {
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.PullRequestRule{
// TODO how do we know if they're required?
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{
PullRequestRule: 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
}
// Copyright 2022 OpenSSF 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"
"regexp"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
var gitCommitHashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
type checkrunsHandler struct {
glClient *gitlab.Client
repourl *Repo
}
func (handler *checkrunsHandler) init(repourl *Repo) {
handler.repourl = repourl
}
func (handler *checkrunsHandler) listCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
var options gitlab.ListProjectPipelinesOptions
if gitCommitHashRegex.MatchString(ref) {
options.SHA = &ref
} else {
options.Ref = &ref
}
// Notes for GitLab ListProjectPipelines endpoint:
// Only full SHA works for SHA param, Short SHA does not work
// Branch names work for Ref Param, tags and SHAs do not work
// Reference: https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines
pipelines, _, err := handler.glClient.Pipelines.ListProjectPipelines(handler.repourl.projectID, &options)
if err != nil {
return nil, fmt.Errorf("request for pipelines returned error: %w", err)
}
return checkRunsFrom(pipelines), nil
}
// Conclusion does not exist in the pipelines for gitlab so I have a placeholder "" as the value.
func checkRunsFrom(data []*gitlab.PipelineInfo) []clients.CheckRun {
var checkRuns []clients.CheckRun
for _, pipelineInfo := range data {
// TODO: Can get more info from GitLab API here (e.g. pipeline name, URL)
// https://docs.gitlab.com/ee/api/pipelines.html#get-a-pipelines-test-report
checkRuns = append(checkRuns, parseGitlabStatus(pipelineInfo))
}
return checkRuns
}
// Conclusion does not exist in the pipelines for gitlab,
// so we parse the status to determine the conclusion if it exists.
func parseGitlabStatus(info *gitlab.PipelineInfo) clients.CheckRun {
checkrun := clients.CheckRun{
URL: info.WebURL,
}
const completed = "completed"
switch info.Status {
case "created", "waiting_for_resource", "preparing", "pending", "scheduled":
checkrun.Status = "queued"
case "running":
checkrun.Status = "in_progress"
case "failed":
checkrun.Status = completed
checkrun.Conclusion = "failure"
case "success":
checkrun.Status = completed
checkrun.Conclusion = "success"
case "canceled":
checkrun.Status = completed
checkrun.Conclusion = "cancelled"
case "skipped":
checkrun.Status = completed
checkrun.Conclusion = "skipped"
case "manual":
checkrun.Status = completed
checkrun.Conclusion = "action_required"
default:
checkrun.Status = info.Status
}
return checkrun
}
// Copyright 2022 OpenSSF 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"
"io"
"log"
"os"
"time"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type repoURL")
)
type Client struct {
repourl *Repo
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
licenses *licensesHandler
tarball *tarballHandler
graphql *graphqlHandler
ctx context.Context
commitDepth int
}
var errRepoAccess = errors.New("repo inaccessible")
// Raise an error if repository access level is private or disabled.
func checkRepoInaccessible(repo *gitlab.Project) error {
if repo.RepositoryAccessLevel == gitlab.DisabledAccessControl {
return fmt.Errorf("%w: %s access level %s",
errRepoAccess, repo.PathWithNamespace, string(repo.RepositoryAccessLevel),
)
}
return nil
}
// 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, commitDepth int) error {
glRepo, ok := inputRepo.(*Repo)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}
// Sanity check.
proj := fmt.Sprintf("%s/%s", glRepo.owner, glRepo.project)
license := true // Get project license information. Used for licenses client.
repo, _, err := client.glClient.Projects.GetProject(proj, &gitlab.GetProjectOptions{License: &license})
if err != nil {
return sce.WithMessage(sce.ErrRepoUnreachable, proj+"\t"+err.Error())
}
if err = checkRepoInaccessible(repo); err != nil {
return sce.WithMessage(sce.ErrRepoUnreachable, err.Error())
}
if commitDepth <= 0 {
client.commitDepth = 30 // default
} else {
client.commitDepth = commitDepth
}
client.repo = repo
client.repourl = &Repo{
scheme: glRepo.scheme,
host: glRepo.host,
owner: glRepo.owner,
project: glRepo.project,
projectID: fmt.Sprint(repo.ID),
defaultBranch: repo.DefaultBranch,
commitSHA: commitSHA,
}
if repo.Owner != nil {
client.repourl.owner = repo.Owner.Username
}
// Init contributorsHandler
client.contributors.init(client.repourl)
// Init commitsHandler
client.commits.init(client.repourl, client.commitDepth)
// 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 languagesHandler
client.licenses.init(client.repourl, repo)
// Init tarballHandler
client.tarball.init(client.ctx, client.repourl, repo, commitSHA)
// Init graphqlHandler
client.graphql.init(client.ctx, client.repourl)
return nil
}
func (client *Client) URI() string {
return fmt.Sprintf("%s/%s/%s", client.repourl.host, client.repourl.owner, client.repourl.project)
}
func (client *Client) LocalPath() (string, error) {
return "", nil
}
func (client *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
return client.tarball.listFiles(predicate)
}
func (client *Client) GetFileReader(filename string) (io.ReadCloser, error) {
return client.tarball.getFile(filename)
}
func (client *Client) ListCommits() ([]clients.Commit, error) {
// Get commits from REST API
commitsRaw, err := client.commits.listRawCommits()
if err != nil {
return []clients.Commit{}, err
}
if len(commitsRaw) < 1 {
return []clients.Commit{}, nil
}
before := commitsRaw[0].CommittedDate
// Get merge request details from GraphQL
// GitLab REST API doesn't provide a way to link Merge Requests and Commits that
// are within them without making a REST call for each commit (~30 by default)
// Making 1 GraphQL query to combine the results of 2 REST calls, we avoid this
// TODO(#3193): Fix the way graphql retrieves merge details to more closely
// line up with commits from listRawCommits
mrDetails, err := client.graphql.getMergeRequestsDetail(before)
if err != nil {
return []clients.Commit{}, err
}
return client.commits.zip(commitsRaw, mrDetails), nil
}
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) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
return nil, fmt.Errorf("GetOrgRepoClient (GitLab): %w", clients.ErrUnsupportedFeature)
}
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()
}
// ListLicenses implements RepoClient.ListLicenses.
func (client *Client) ListLicenses() ([]clients.License, error) {
return client.licenses.listLicenses()
}
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 CreateGitlabClient(ctx context.Context, host string) (clients.RepoClient, error) {
token := os.Getenv("GITLAB_AUTH_TOKEN")
return CreateGitlabClientWithToken(ctx, token, host)
}
func CreateGitlabClientWithToken(ctx context.Context, token, host string) (clients.RepoClient, error) {
url := "https://" + host
client, err := gitlab.NewClient(token, gitlab.WithBaseURL(url))
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,
},
licenses: &licensesHandler{},
tarball: &tarballHandler{},
graphql: &graphqlHandler{},
}, 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)
}
// Copyright 2022 OpenSSF 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"
"os"
"strconv"
"strings"
"sync"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type commitsHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
commitsRaw []*gitlab.Commit
commitDepth int
}
func (handler *commitsHandler) init(repourl *Repo, commitDepth int) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.commitDepth = commitDepth
}
func (handler *commitsHandler) setup() error {
handler.once.Do(func() {
var commits []*gitlab.Commit
opt := gitlab.ListOptions{
Page: 1,
PerPage: handler.commitDepth,
}
for {
c, resp, err := handler.glClient.Commits.ListCommits(handler.repourl.projectID,
&gitlab.ListCommitsOptions{
RefName: &handler.repourl.commitSHA,
ListOptions: opt,
})
if err != nil {
handler.errSetup = fmt.Errorf("request for commits failed with %w", err)
return
}
commits = append(commits, c...)
if len(commits) >= handler.commitDepth {
commits = commits[:handler.commitDepth]
break
}
// Exit the loop when we've seen all pages.
if resp.NextPage == 0 {
break
}
// Update the page number to get the next page.
opt.Page = resp.NextPage
}
handler.commitsRaw = commits
if handler.repourl.commitSHA != clients.HeadSHA {
//nolint:lll
// TODO(#3193): Fix the way graphql retrieves merge details to more closely
// line up with commits from listRawCommits
fmt.Fprintln(os.Stderr, "Scorecard may be missing merge requests when running on non-HEAD of a GitLab repo. Code-Review scores may be lower.")
}
})
return handler.errSetup
}
func (handler *commitsHandler) listRawCommits() ([]*gitlab.Commit, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during commitsHandler.setup: %w", err)
}
return handler.commitsRaw, nil
}
// zip combines Commit information from the GitLab REST API with MergeRequests
// information from the GitLab GraphQL API. The REST API doesn't provide any way to
// get from Commits -> MRs that they were part of or vice-versa (MRs -> commits they
// contain), except through a separate API call. Instead of calling the REST API
// len(commits) times to get the associated MR, we make 3 calls (2 REST, 1 GraphQL).
func (handler *commitsHandler) zip(commitsRaw []*gitlab.Commit, data graphqlData) []clients.Commit {
commitToMRIID := make(map[string]string) // which mr does a commit belong to?
for i := range data.Project.MergeRequests.Nodes {
mr := data.Project.MergeRequests.Nodes[i]
for _, commit := range mr.Commits.Nodes {
commitToMRIID[commit.SHA] = mr.IID
}
commitToMRIID[mr.MergeCommitSHA] = mr.IID
}
iidToMr := make(map[string]clients.PullRequest)
for i := range data.Project.MergeRequests.Nodes {
mr := data.Project.MergeRequests.Nodes[i]
// Two GitLab APIs for reviews (reviews vs. approvals)
// Use a map to consolidate results from both APIs by the user ID who performed review
reviews := make(map[string]clients.Review)
for _, reviewer := range mr.Reviewers.Nodes {
reviews[reviewer.Username] = clients.Review{
Author: &clients.User{Login: reviewer.Username},
State: "COMMENTED",
}
}
if fmt.Sprintf("%v", mr.IID) != mr.IID {
continue
}
// Check approvers
for _, approver := range mr.Approvers.Nodes {
reviews[approver.Username] = clients.Review{
Author: &clients.User{Login: approver.Username},
State: "APPROVED",
}
break
}
// Check reviewers (sometimes unofficial approvals end up here)
for _, reviewer := range mr.Reviewers.Nodes {
if reviewer.MergeRequestInteraction.ReviewState != "REVIEWED" {
continue
}
reviews[reviewer.Username] = clients.Review{
Author: &clients.User{Login: reviewer.Username},
State: "APPROVED",
}
break
}
vals := []clients.Review{}
for _, v := range reviews {
vals = append(vals, v)
}
var mrno int
mrno, err := strconv.Atoi(mr.IID)
if err != nil {
mrno = mr.ID.ID
}
iidToMr[mr.IID] = clients.PullRequest{
Number: mrno,
MergedAt: mr.MergedAt,
HeadSHA: mr.MergeCommitSHA,
Author: clients.User{Login: mr.Author.Username, ID: int64(mr.Author.ID.ID)},
Reviews: vals,
MergedBy: clients.User{Login: mr.MergedBy.Username, ID: int64(mr.MergedBy.ID.ID)},
}
}
// Associate Merge Requests with Commits based on the GitLab Merge Request IID
commits := []clients.Commit{}
for _, cRaw := range commitsRaw {
// Get IID of Merge Request that this commit was merged as part of
mrIID := commitToMRIID[cRaw.ID]
associatedMr := iidToMr[mrIID]
commits = append(commits,
clients.Commit{
CommittedDate: *cRaw.CommittedDate,
Message: cRaw.Message,
SHA: cRaw.ID,
AssociatedMergeRequest: associatedMr,
})
}
return commits
}
// Expected email form: <firstname>.<lastname>@<namespace>.com.
func parseEmailToName(email string) string {
if strings.Contains(email, ".") {
s := strings.Split(email, ".")
firstName := s[0]
lastName := strings.Split(s[1], "@")[0]
return firstName + " " + lastName
}
return email
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type contributorsHandler struct {
fnContributors retrieveContributorFn
fnUsers retrieveUserFn
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
contributors []clients.User
}
func (handler *contributorsHandler) init(repourl *Repo) {
handler.repourl = repourl
handler.errSetup = nil
handler.once = new(sync.Once)
handler.fnContributors = handler.retrieveContributors
handler.fnUsers = handler.retrieveUsers
}
type (
retrieveContributorFn func(string) ([]*gitlab.Contributor, error)
retrieveUserFn func(string) ([]*gitlab.User, error)
)
func (handler *contributorsHandler) retrieveContributors(project string) ([]*gitlab.Contributor, error) {
var contribs []*gitlab.Contributor
i := 1
for {
c, _, err := handler.glClient.Repositories.Contributors(
project,
&gitlab.ListContributorsOptions{
ListOptions: gitlab.ListOptions{
Page: i,
PerPage: 100,
},
},
)
if err != nil {
//nolint:wrapcheck
return nil, err
}
if len(c) == 0 {
break
}
i++
contribs = append(contribs, c...)
}
return contribs, nil
}
func (handler *contributorsHandler) retrieveUsers(queryName string) ([]*gitlab.User, error) {
users, _, err := handler.glClient.Search.Users(queryName, &gitlab.SearchOptions{})
if err != nil {
//nolint:wrapcheck
return nil, err
}
return users, nil
}
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.fnContributors(handler.repourl.projectID)
if err != nil {
handler.errSetup = fmt.Errorf("error during ListContributors: %w", err)
return
}
for _, contrib := range contribs {
if contrib.Name == "" {
continue
}
users, err := handler.fnUsers(contrib.Name)
if err != nil {
handler.errSetup = fmt.Errorf("error during Users.Get: %w", err)
return
} else if len(users) == 0 && contrib.Email != "" {
// parseEmailToName is declared in commits.go
users, err = handler.fnUsers(parseEmailToName(contrib.Email))
if err != nil {
handler.errSetup = fmt.Errorf("error during Users.Get: %w", err)
return
}
}
user := &gitlab.User{}
if len(users) == 0 {
user.ID = 0
user.Organization = ""
user.Bot = false
} else {
user = users[0]
}
contributor := clients.User{
Login: contrib.Email,
Companies: []string{user.Organization},
NumContributions: contrib.Commits,
ID: int64(user.ID),
IsBot: user.Bot,
}
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
}
// Copyright 2023 OpenSSF 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 (
"context"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"time"
"github.com/shurcooL/graphql"
"golang.org/x/oauth2"
)
type graphqlHandler struct {
err error
client *http.Client
graphClient *graphql.Client
ctx context.Context
repourl *Repo
}
func (handler *graphqlHandler) init(ctx context.Context, repourl *Repo) {
handler.ctx = ctx
handler.repourl = repourl
handler.err = nil
src := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: os.Getenv("GITLAB_AUTH_TOKEN")},
)
handler.client = oauth2.NewClient(ctx, src)
handler.graphClient = graphql.NewClient(fmt.Sprintf("https://%s/api/graphql", repourl.Host()), handler.client)
}
type graphqlData struct {
Project struct {
MergeRequests struct {
Nodes []graphqlMergeRequestNode `graphql:"nodes"`
} `graphql:"mergeRequests(sort: MERGED_AT_DESC, state: merged, mergedBefore: $mergedBefore)"`
} `graphql:"project(fullPath: $fullPath)"`
QueryComplexity struct {
Limit int `graphql:"limit"`
Score int `graphql:"score"`
} `graphql:"queryComplexity"`
}
//nolint:govet
type graphqlMergeRequestNode struct {
ID GitlabGID `graphql:"id"`
IID string `graphql:"iid"`
MergedAt time.Time `graphql:"mergedAt"`
Author struct {
Username string `graphql:"username"`
ID GitlabGID `graphql:"id"`
} `graphql:"author"`
MergedBy struct {
Username string `graphql:"username"`
ID GitlabGID `graphql:"id"`
} `graphql:"mergeUser"`
Commits struct {
Nodes []struct {
SHA string `graphql:"sha"`
} `graphql:"nodes"`
} `graphql:"commits"`
Reviewers struct {
Nodes []struct {
Username string `graphql:"username"`
ID GitlabGID `graphql:"id"`
MergeRequestInteraction struct {
ReviewState string `graphql:"reviewState"`
} `graphql:"mergeRequestInteraction"`
} `graphql:"nodes"`
} `graphql:"reviewers"`
Approvers struct {
Nodes []struct {
Username string `graphql:"username"`
ID GitlabGID `graphql:"id"`
} `graphql:"nodes"`
} `graphql:"approvedBy"`
MergeCommitSHA string `graphql:"mergeCommitSha"`
// Labels struct {
// Nodes []struct {
// Title string `graphql:"title"`
// } `graphql:"nodes"`
// } `graphql:"labels"`
}
type GitlabGID struct {
Type string
ID int
}
var errGitlabID = errors.New("failed to parse gitlab id")
func (g *GitlabGID) UnmarshalJSON(data []byte) error {
re := regexp.MustCompile(`gid:\/\/gitlab\/(\w+)\/(\d+)`)
m := re.FindStringSubmatch(string(data))
if len(m) < 3 {
return fmt.Errorf("%w: %s", errGitlabID, string(data))
}
g.Type = m[1]
id, err := strconv.Atoi(m[2])
if err != nil {
return fmt.Errorf("gid parse error: %w", err)
}
g.ID = id
return nil
}
func (handler *graphqlHandler) getMergeRequestsDetail(before *time.Time) (graphqlData, error) {
data := graphqlData{}
path := fmt.Sprintf("%s/%s", handler.repourl.owner, handler.repourl.project)
params := map[string]interface{}{
"fullPath": path,
"mergedBefore": before,
}
err := handler.graphClient.Query(context.Background(), &data, params)
if err != nil {
return graphqlData{}, fmt.Errorf("couldn't query gitlab graphql for merge requests: %w", err)
}
return data, nil
}
// Copyright 2022 OpenSSF 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"
"net/http"
"sync"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type issuesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
issues []clients.Issue
}
func (handler *issuesHandler) init(repourl *Repo) {
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
// barbaric, however I couldn't find a better way in the docs.
projMemberships, resp, err := handler.glClient.ProjectMembers.ListAllProjectMembers(
handler.repourl.projectID, &gitlab.ListProjectMembersOptions{})
if err != nil && resp.StatusCode != http.StatusUnauthorized {
handler.errSetup = fmt.Errorf("unable to find access tokens associated with the project id: %w", err)
return
} else if resp.StatusCode == http.StatusUnauthorized {
handler.errSetup = fmt.Errorf("insufficient permissions to check issue author associations %w", err)
return
}
var authorAssociation clients.RepoAssociation
for _, issue := range issues {
for _, m := range projMemberships {
if issue.Author.ID == m.ID {
authorAssociation = accessLevelToRepoAssociation(m.AccessLevel)
}
}
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,
})
}
})
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 accessLevelToRepoAssociation(l gitlab.AccessLevelValue) clients.RepoAssociation {
switch l {
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
}
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type languagesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
languages []clients.Language
}
func (handler *languagesHandler) init(repourl *Repo) {
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
}
// Copyright 2022 OpenSSF 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"
"regexp"
"sync"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type licensesHandler struct {
glProject *gitlab.Project
once *sync.Once
errSetup error
repourl *Repo
licenses []clients.License
}
func (handler *licensesHandler) init(repourl *Repo, project *gitlab.Project) {
handler.repourl = repourl
handler.glProject = project
handler.errSetup = nil
handler.once = new(sync.Once)
}
var errLicenseURLParse = errors.New("couldn't parse gitlab repo license url")
func (handler *licensesHandler) setup() error {
handler.once.Do(func() {
l := handler.glProject.License
// No registered license on GitLab repo, use file-based license detection instead
if l == nil {
return
}
ptn, err := regexp.Compile(fmt.Sprintf("%s/-/blob/(?:\\w+)/(.*)", handler.repourl.URI()))
if err != nil {
handler.errSetup = fmt.Errorf("couldn't parse license url: %w", err)
return
}
m := ptn.FindStringSubmatch(handler.glProject.LicenseURL)
if len(m) < 2 {
handler.errSetup = fmt.Errorf("%w: %s", errLicenseURLParse, handler.glProject.LicenseURL)
return
}
path := m[1]
handler.licenses = append(handler.licenses,
clients.License{
Key: l.Key,
Name: l.Name,
Path: path,
SPDXId: l.Key,
},
)
handler.errSetup = nil
})
return handler.errSetup
}
// Currently listLicenses() returns the percentages (truncated) of each language in the project.
func (handler *licensesHandler) listLicenses() ([]clients.License, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during licensesHandler.setup: %w", err)
}
return handler.licenses, nil
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
)
type projectHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
createdAt time.Time
archived bool
}
func (handler *projectHandler) init(repourl *Repo) {
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
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type releasesHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
releases []clients.Release
}
func (handler *releasesHandler) init(repourl *Repo) {
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,
TargetCommitish: r.CommitPath,
}
if len(r.Assets.Links) > 0 {
release.URL = r.Assets.Links[0].DirectAssetURL
}
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
}
// Copyright 2022 OpenSSF 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 (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
sce "github.com/ossf/scorecard/v5/errors"
)
type Repo struct {
scheme string
host string
owner string
project string
projectID string
defaultBranch string
commitSHA string
metadata []string
}
var errInvalidGitlabRepoURL = errors.New("repo is not a gitlab repo")
// Parses input string into repoURL struct
/*
* Accepted input string formats are as follows:
* "gitlab.<companyDomain:string>.com/<owner:string>/<projectID:string>"
* "https://gitlab.<companyDomain:string>.com/<owner:string>/<projectID:string>"
The following input format is not supported:
* https://gitlab.<companyDomain:string>.com/projects/<projectID:int>
*/
func (r *Repo) parse(input string) error {
var t string
c := strings.Split(input, "/")
switch l := len(c); {
// owner/repo format is not supported for gitlab, it's github-only
case l == 2:
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("gitlab repo must specify host: %s", input))
case l >= 3:
t = input
}
u, err := url.Parse(withDefaultScheme(t))
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse: %v", err))
}
// fixup the URL, for situations where GL_HOST contains part of the path
// https://github.com/ossf/scorecard/issues/3696
if h := os.Getenv("GL_HOST"); h != "" {
hostURL, err := url.Parse(withDefaultScheme(h))
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("url.Parse GL_HOST: %v", err))
}
// only modify behavior of repos which fall under GL_HOST
if hostURL.Host == u.Host {
// without the scheme and without trailing slashes
u.Host = hostURL.Host + strings.TrimRight(hostURL.Path, "/")
// remove any part of the path which belongs to the host
u.Path = strings.TrimPrefix(u.Path, hostURL.Path)
}
}
const splitLen = 2
split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen)
if len(split) != splitLen {
return sce.WithMessage(sce.ErrInvalidURL, fmt.Sprintf("%v. Expected full repository url", input))
}
r.scheme, r.host, r.owner, r.project = u.Scheme, u.Host, split[0], split[1]
return nil
}
// Allow skipping scheme for ease-of-use, default to https.
func withDefaultScheme(uri string) string {
if strings.Contains(uri, "://") {
return uri
}
return "https://" + uri
}
// URI implements Repo.URI().
func (r *Repo) URI() string {
return fmt.Sprintf("%s/%s/%s", r.host, r.owner, r.project)
}
func (r *Repo) Host() string {
return r.host
}
// String implements Repo.String.
func (r *Repo) String() string {
return fmt.Sprintf("%s-%s_%s", r.host, r.owner, r.project)
}
// IsValid implements Repo.IsValid.
func (r *Repo) IsValid() error {
if strings.TrimSpace(r.owner) == "" || strings.TrimSpace(r.project) == "" {
return sce.WithMessage(sce.ErrInvalidURL, "expected full project url: "+r.URI())
}
if strings.Contains(r.host, "gitlab.") {
return nil
}
if strings.EqualFold(r.host, "github.com") {
return fmt.Errorf("%w: %s", errInvalidGitlabRepoURL, r.host)
}
// intentionally pass empty token
// "When accessed without authentication, only public projects with simple fields are returned."
// https://docs.gitlab.com/ee/api/projects.html#list-all-projects
baseURL := fmt.Sprintf("%s://%s", r.scheme, r.host)
client, err := gitlab.NewClient("", gitlab.WithBaseURL(baseURL))
if err != nil {
return sce.WithMessage(err,
fmt.Sprintf("couldn't create gitlab client for %s", r.host),
)
}
_, resp, err := client.Projects.ListProjects(&gitlab.ListProjectsOptions{})
if resp == nil || resp.StatusCode != http.StatusOK {
return sce.WithMessage(sce.ErrRepoUnreachable,
fmt.Sprintf("couldn't reach gitlab instance at %s: %v", r.host, err),
)
}
if err != nil {
return sce.WithMessage(err,
fmt.Sprintf("error when connecting to gitlab instance at %s", r.host),
)
}
return nil
}
func (r *Repo) AppendMetadata(metadata ...string) {
r.metadata = append(r.metadata, metadata...)
}
// Metadata implements Repo.Metadata.
func (r *Repo) Metadata() []string {
return r.metadata
}
// Path() implements RepoClient.Path.
func (r *Repo) Path() string {
return fmt.Sprintf("%s/%s", r.owner, r.project)
}
// MakeGitlabRepo takes input of forms in parse and returns and implementation
// of clients.Repo interface.
func MakeGitlabRepo(input string) (clients.Repo, error) {
var repo Repo
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 in IsValid: %w", err)
}
return &repo, nil
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
var errEmptyQuery = errors.New("search query is empty")
type searchHandler struct {
glClient *gitlab.Client
repourl *Repo
}
func (handler *searchHandler) init(repourl *Repo) {
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
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type searchCommitsHandler struct {
glClient *gitlab.Client
repourl *Repo
}
func (handler *searchCommitsHandler) init(repourl *Repo) {
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.buildQuery: %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 their 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
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type statusesHandler struct {
glClient *gitlab.Client
repourl *Repo
}
func (handler *statusesHandler) init(repourl *Repo) {
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: commitStatus.Name,
URL: commitStatus.TargetURL,
TargetURL: commitStatus.TargetURL,
})
}
return statuses
}
// Copyright 2021 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 (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
gitlab "gitlab.com/gitlab-org/api/client-go"
sce "github.com/ossf/scorecard/v5/errors"
)
const (
repoDir = "project*"
repoFilename = "gitlabproject*.tar.gz"
)
var (
errTarballNotFound = errors.New("tarball not found")
errTarballCorrupted = errors.New("corrupted tarball")
errZipSlip = errors.New("ZipSlip path detected")
)
func extractAndValidateArchivePath(path, dest string) (string, error) {
const splitLength = 2
// The tarball will have a top-level directory which contains all the repository files.
// Discard the directory and only keep the actual files.
names := strings.SplitN(path, "/", splitLength)
if len(names) < splitLength {
return dest, nil
}
if names[1] == "" {
return dest, nil
}
// Check for ZipSlip: https://snyk.io/research/zip-slip-vulnerability
cleanpath := filepath.Join(dest, names[1])
if !strings.HasPrefix(cleanpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return "", fmt.Errorf("%w: %s", errZipSlip, names[1])
}
return cleanpath, nil
}
type tarballHandler struct {
errSetup error
once *sync.Once
ctx context.Context
repo *gitlab.Project
repourl *Repo
commitSHA string
tempDir string
tempTarFile string
files []string
}
type gitLabLint struct {
MergedYaml string `json:"merged_yaml"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Valid bool `json:"valid"`
}
func (handler *tarballHandler) init(ctx context.Context, repourl *Repo, repo *gitlab.Project, commitSHA string) {
handler.errSetup = nil
handler.once = new(sync.Once)
handler.ctx = ctx
handler.repo = repo
handler.repourl = repourl
handler.commitSHA = commitSHA
}
func (handler *tarballHandler) setup() error {
handler.once.Do(func() {
// cleanup any previous state.
if err := handler.cleanup(); err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
// setup tem dir/files and download repo tarball.
if err := handler.getTarball(); errors.Is(err, errTarballNotFound) {
log.Printf("unable to get tarball %v. Skipping...", err)
return
} else if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return
}
// extract file names and content from tarball.
if err := handler.extractTarball(); errors.Is(err, errTarballCorrupted) {
log.Printf("unable to extract tarball %v. Skipping...", err)
} else if err != nil {
handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
})
return handler.errSetup
}
func (handler *tarballHandler) getTarball() error {
url := fmt.Sprintf("https://%s/api/v4/projects/%d/repository/archive.tar.gz?sha=%s",
handler.repourl.Host(), handler.repo.ID, handler.commitSHA)
// Create a temp file. This automatically appends a random number to the name.
tempDir, err := os.MkdirTemp("", repoDir)
if err != nil {
return fmt.Errorf("os.MkdirTemp: %w", err)
}
repoFile, err := os.CreateTemp(tempDir, repoFilename)
if err != nil {
return fmt.Errorf("%w io.Copy: %w", errTarballNotFound, err)
}
defer repoFile.Close()
err = handler.apiFunction(url, tempDir, repoFile)
if err != nil {
return fmt.Errorf("gitlab.apiFunction: %w", err)
}
// Gitlab url for pulling combined ci
url = fmt.Sprintf("https://%s/api/v4/projects/%d/ci/lint",
handler.repourl.Host(), handler.repo.ID)
ciFile, err := os.CreateTemp(tempDir, "gitlabscorecard_lint*.json")
if err != nil {
return fmt.Errorf("os.CreateTemp: %w", err)
}
err = handler.apiFunction(url, tempDir, ciFile)
if err != nil {
return fmt.Errorf("gitlab.apiFunction: %w", err)
}
byteValue, err := os.ReadFile(ciFile.Name())
if err != nil {
return fmt.Errorf("os.ReadFile: %w", err)
}
var result gitLabLint
err = json.Unmarshal(byteValue, &result)
if err != nil {
return fmt.Errorf("json.Unmarshal: %w", err)
}
ciYaml, err := os.Create(tempDir + "/gitlabscorecard_flattened_ci.yaml")
if err != nil {
return fmt.Errorf("os.CreateTemp: %w", err)
}
defer ciYaml.Close()
_, err = ciYaml.WriteString(result.MergedYaml)
if err != nil {
return fmt.Errorf("os.File.WriteString: %w", err)
}
err = ciYaml.Sync()
if err != nil {
return fmt.Errorf("os.File.Sync: %w", err)
}
handler.tempDir = tempDir
handler.tempTarFile = repoFile.Name()
handler.files = append(handler.files,
strings.TrimPrefix(ciYaml.Name(), filepath.Clean(handler.tempDir)+string(os.PathSeparator)))
return nil
}
func (handler *tarballHandler) apiFunction(url, tempDir string, repoFile *os.File) error {
req, err := http.NewRequestWithContext(handler.ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("http.NewRequestWithContext: %w", err)
}
req.Header.Set("PRIVATE-TOKEN", os.Getenv("GITLAB_AUTH_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("%w io.Copy: %w", errTarballNotFound, err)
}
defer resp.Body.Close()
// Handler 400/404 errors.
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest:
return fmt.Errorf("%w io.Copy: %w", errTarballNotFound, err)
}
if _, err := io.Copy(repoFile, resp.Body); err != nil {
// If the incoming tarball is corrupted or the server times out.
return fmt.Errorf("%w io.Copy: %w", errTarballNotFound, err)
}
return nil
}
//nolint:gocognit
func (handler *tarballHandler) extractTarball() error {
in, err := os.OpenFile(handler.tempTarFile, os.O_RDONLY, 0o644)
if err != nil {
return fmt.Errorf("os.OpenFile: %w", err)
}
gz, err := gzip.NewReader(in)
if err != nil {
return fmt.Errorf("%w: gzip.NewReader %v %w", errTarballCorrupted, handler.tempTarFile, err)
}
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("%w tarReader.Next: %w", errTarballCorrupted, err)
}
switch header.Typeflag {
case tar.TypeDir:
dirpath, err := extractAndValidateArchivePath(header.Name, handler.tempDir)
if err != nil {
return err
}
if dirpath == filepath.Clean(handler.tempDir) {
continue
}
if err := os.Mkdir(dirpath, 0o755); err != nil {
return fmt.Errorf("error during os.Mkdir: %w", err)
}
case tar.TypeReg:
if header.Size <= 0 {
continue
}
filenamepath, err := extractAndValidateArchivePath(header.Name, handler.tempDir)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Dir(filenamepath)); os.IsNotExist(err) {
if err := os.Mkdir(filepath.Dir(filenamepath), 0o755); err != nil {
return fmt.Errorf("os.Mkdir: %w", err)
}
}
outFile, err := os.Create(filenamepath)
if err != nil {
return fmt.Errorf("os.Create: %w", err)
}
//nolint:gosec
// Potential for DoS vulnerability via decompression bomb.
// Since such an attack will only impact a single shard, ignoring this for now.
if _, err := io.Copy(outFile, tr); err != nil {
return fmt.Errorf("%w io.Copy: %w", errTarballCorrupted, err)
}
outFile.Close()
handler.files = append(handler.files,
strings.TrimPrefix(filenamepath, filepath.Clean(handler.tempDir)+string(os.PathSeparator)))
case tar.TypeXGlobalHeader, tar.TypeSymlink:
continue
default:
log.Printf("Unknown file type %s: '%s'", header.Name, string(header.Typeflag))
continue
}
}
return nil
}
func (handler *tarballHandler) listFiles(predicate func(string) (bool, error)) ([]string, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during tarballHandler.setup: %w", err)
}
ret := make([]string, 0)
for _, file := range handler.files {
matches, err := predicate(file)
if err != nil {
return nil, err
}
if matches {
ret = append(ret, file)
}
}
return ret, nil
}
func (handler *tarballHandler) getFile(filename string) (*os.File, error) {
if err := handler.setup(); err != nil {
return nil, fmt.Errorf("error during tarballHandler.setup: %w", err)
}
f, err := os.Open(filepath.Join(handler.tempDir, filename))
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
func (handler *tarballHandler) cleanup() error {
if err := os.RemoveAll(handler.tempDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("os.Remove: %w", err)
}
// Remove old file so we don't iterate through them.
handler.files = nil
return nil
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type webhookHandler struct {
glClient *gitlab.Client
once *sync.Once
errSetup error
repourl *Repo
webhooks []clients.Webhook
}
func (handler *webhookHandler) init(repourl *Repo) {
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
}
// Copyright 2022 OpenSSF 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"
gitlab "gitlab.com/gitlab-org/api/client-go"
"github.com/ossf/scorecard/v5/clients"
)
type workflowsHandler struct {
glClient *gitlab.Client
repourl *Repo
}
func (handler *workflowsHandler) init(repourl *Repo) {
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
}
// avoid memory aliasing by returning a new copy.
func strptr(s string) *string {
return &s
}
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: strptr(job.Pipeline.Sha),
URL: job.WebURL,
})
continue
}
}
}
return workflowRuns
}
// Copyright 2021 OpenSSF 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 localdir implements RepoClient on local source code.
package localdir
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
clients "github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/log"
)
var (
_ clients.RepoClient = &Client{}
errInputRepoType = errors.New("input repo should be of type repoLocal")
)
//nolint:govet
type Client struct {
logger *log.Logger
ctx context.Context
path string
once sync.Once
errFiles error
files []string
commitDepth int
}
// InitRepo sets up the local repo.
func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error {
localRepo, ok := inputRepo.(*Repo)
if !ok {
return fmt.Errorf("%w: %v", errInputRepoType, inputRepo)
}
if commitDepth <= 0 {
client.commitDepth = 30 // default
} else {
client.commitDepth = commitDepth
}
client.path = strings.TrimPrefix(localRepo.URI(), "file://")
return nil
}
// URI implements RepoClient.URI.
func (client *Client) URI() string {
return fmt.Sprintf("file://%s", client.path)
}
// IsArchived implements RepoClient.IsArchived.
func (client *Client) IsArchived() (bool, error) {
return false, fmt.Errorf("IsArchived: %w", clients.ErrUnsupportedFeature)
}
func isDir(p string) (bool, error) {
fileInfo, err := os.Stat(p)
if err != nil {
return false, fmt.Errorf("%w", err)
}
return fileInfo.IsDir(), nil
}
func trimPrefix(pathfn, clientPath string) string {
cleanPath := path.Clean(pathfn)
prefix := fmt.Sprintf("%s%s", clientPath, string(os.PathSeparator))
return strings.TrimPrefix(cleanPath, prefix)
}
func listFiles(clientPath string) ([]string, error) {
files := []string{}
err := filepath.Walk(clientPath, func(pathfn string, info fs.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failure accessing path %q: %w", pathfn, err)
}
// Skip directories.
d, err := isDir(pathfn)
if err != nil {
return err
}
if d {
// Check if the directory is .git. Use filepath.Base for compatibility across different OS path separators.
// ignoring the .git folder.
if filepath.Base(pathfn) == ".git" {
return fs.SkipDir
}
return nil
}
// Remove prefix of the folder.
p := trimPrefix(pathfn, clientPath)
files = append(files, p)
return nil
})
if err != nil {
return nil, fmt.Errorf("error walking the path %q: %w", clientPath, err)
}
return files, nil
}
func applyPredicate(
clientFiles []string,
errFiles error,
predicate func(string) (bool, error),
) ([]string, error) {
if errFiles != nil {
return nil, errFiles
}
files := []string{}
for _, pathfn := range clientFiles {
matches, err := predicate(pathfn)
if err != nil {
return nil, err
}
if matches {
files = append(files, pathfn)
}
}
return files, nil
}
// LocalPath implements RepoClient.LocalPath.
func (client *Client) LocalPath() (string, error) {
clientPath, err := filepath.Abs(client.path)
if err != nil {
return "", fmt.Errorf("error during filepath.Abs: %w", err)
}
return clientPath, nil
}
// ListFiles implements RepoClient.ListFiles.
func (client *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
client.once.Do(func() {
client.files, client.errFiles = listFiles(client.path)
})
return applyPredicate(client.files, client.errFiles, predicate)
}
func getFile(clientpath, filename string) (*os.File, error) {
// Note: the filenames do not contain the original path - see ListFiles().
fn := path.Join(clientpath, filename)
f, err := os.Open(fn)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
// GetFileReader implements RepoClient.GetFileReader.
func (client *Client) GetFileReader(filename string) (io.ReadCloser, error) {
return getFile(client.path, filename)
}
// GetBranch implements RepoClient.GetBranch.
func (client *Client) GetBranch(branch string) (*clients.BranchRef, error) {
return nil, fmt.Errorf("ListBranches: %w", clients.ErrUnsupportedFeature)
}
// GetDefaultBranch implements RepoClient.GetDefaultBranch.
func (client *Client) GetDefaultBranch() (*clients.BranchRef, error) {
return nil, fmt.Errorf("GetDefaultBranch: %w", clients.ErrUnsupportedFeature)
}
// GetDefaultBranchName implements RepoClient.GetDefaultBranchName.
func (client *Client) GetDefaultBranchName() (string, error) {
return "", fmt.Errorf("GetDefaultBranchName: %w", clients.ErrUnsupportedFeature)
}
// ListCommits implements RepoClient.ListCommits.
func (client *Client) ListCommits() ([]clients.Commit, error) {
return nil, fmt.Errorf("ListCommits: %w", clients.ErrUnsupportedFeature)
}
// ListIssues implements RepoClient.ListIssues.
func (client *Client) ListIssues() ([]clients.Issue, error) {
return nil, fmt.Errorf("ListIssues: %w", clients.ErrUnsupportedFeature)
}
// ListReleases implements RepoClient.ListReleases.
func (client *Client) ListReleases() ([]clients.Release, error) {
return nil, fmt.Errorf("ListReleases: %w", clients.ErrUnsupportedFeature)
}
// ListContributors implements RepoClient.ListContributors.
func (client *Client) ListContributors() ([]clients.User, error) {
return nil, fmt.Errorf("ListContributors: %w", clients.ErrUnsupportedFeature)
}
// ListSuccessfulWorkflowRuns implements RepoClient.WorkflowRunsByFilename.
func (client *Client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return nil, fmt.Errorf("ListSuccessfulWorkflowRuns: %w", clients.ErrUnsupportedFeature)
}
// ListCheckRunsForRef implements RepoClient.ListCheckRunsForRef.
func (client *Client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return nil, fmt.Errorf("ListCheckRunsForRef: %w", clients.ErrUnsupportedFeature)
}
// ListStatuses implements RepoClient.ListStatuses.
func (client *Client) ListStatuses(ref string) ([]clients.Status, error) {
return nil, fmt.Errorf("ListStatuses: %w", clients.ErrUnsupportedFeature)
}
// ListWebhooks implements RepoClient.ListWebhooks.
func (client *Client) ListWebhooks() ([]clients.Webhook, error) {
return nil, fmt.Errorf("ListWebhooks: %w", clients.ErrUnsupportedFeature)
}
// Search implements RepoClient.Search.
func (client *Client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
return clients.SearchResponse{}, fmt.Errorf("Search: %w", clients.ErrUnsupportedFeature)
}
// SearchCommits implements RepoClient.SearchCommits.
func (client *Client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return nil, fmt.Errorf("Search: %w", clients.ErrUnsupportedFeature)
}
func (client *Client) Close() error {
return nil
}
// ListProgrammingLanguages implements RepoClient.ListProgrammingLanguages.
// TODO: add ListProgrammingLanguages support for local directories.
func (client *Client) ListProgrammingLanguages() ([]clients.Language, error) {
// for now just return all programming languages
return []clients.Language{{Name: clients.All, NumLines: 1}}, nil
}
// ListLicenses implements RepoClient.ListLicenses.
// TODO: add ListLicenses support for local directories.
func (client *Client) ListLicenses() ([]clients.License, error) {
return nil, fmt.Errorf("ListLicenses: %w", clients.ErrUnsupportedFeature)
}
func (client *Client) GetCreatedAt() (time.Time, error) {
return time.Time{}, fmt.Errorf("GetCreatedAt: %w", clients.ErrUnsupportedFeature)
}
func (client *Client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
return nil, fmt.Errorf("GetOrgRepoClient: %w", clients.ErrUnsupportedFeature)
}
// CreateLocalDirClient returns a client which implements RepoClient interface.
func CreateLocalDirClient(ctx context.Context, logger *log.Logger) clients.RepoClient {
return &Client{
ctx: ctx,
logger: logger,
}
}
// Copyright 2021 OpenSSF 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 localdir is local repo containing source code.
package localdir
import (
"errors"
"fmt"
"os"
"path"
clients "github.com/ossf/scorecard/v5/clients"
)
var errNotDirectory = errors.New("not a directory")
type Repo struct {
path string
metadata []string
}
// URI implements Repo.URI().
func (r *Repo) URI() string {
return fmt.Sprintf("file://%s", r.path)
}
func (r *Repo) Host() string {
return ""
}
// String implements Repo.String.
func (r *Repo) String() string {
return r.URI()
}
// IsValid implements Repo.IsValid.
func (r *Repo) IsValid() error {
f, err := os.Stat(r.path)
if err != nil {
return fmt.Errorf("%w", err)
}
if !f.IsDir() {
return fmt.Errorf("%w", errNotDirectory)
}
return nil
}
// Metadata implements Repo.Metadata.
func (r *Repo) Metadata() []string {
return []string{}
}
// AppendMetadata implements Repo.AppendMetadata.
func (r *Repo) AppendMetadata(m ...string) {
r.metadata = append(r.metadata, m...)
}
// Path() implements RepoClient.Path.
func (r *Repo) Path() string {
return r.path
}
// MakeLocalDirRepo returns an implementation of clients.Repo interface.
func MakeLocalDirRepo(pathfn string) (clients.Repo, error) {
p := path.Clean(pathfn)
repo := &Repo{
path: p,
}
if err := repo.IsValid(); err != nil {
return nil, fmt.Errorf("error in IsValid: %w", err)
}
return repo, nil
}
// Copyright 2023 OpenSSF 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 ossfuzz
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/ossf/scorecard/v5/clients"
)
const (
StatusURL = "https://oss-fuzz-build-logs.storage.googleapis.com/status.json"
)
var (
errUnreachableStatusFile = errors.New("could not fetch OSS Fuzz status file")
errMalformedURL = errors.New("malformed repo url")
)
type client struct {
ctx context.Context
err error
projects map[string]bool
statusURL string
once sync.Once
}
type ossFuzzStatus struct {
Projects []struct {
RepoURI string `json:"main_repo"`
} `json:"projects"`
}
// CreateOSSFuzzClient returns a client which implements RepoClient interface.
func CreateOSSFuzzClient(ossFuzzStatusURL string) clients.RepoClient {
return &client{
ctx: context.Background(),
statusURL: ossFuzzStatusURL,
projects: map[string]bool{},
}
}
// CreateOSSFuzzClientEager returns a OSS Fuzz Client which has already fetched and parsed the status file.
func CreateOSSFuzzClientEager(ossFuzzStatusURL string) (clients.RepoClient, error) {
c := client{
ctx: context.Background(),
statusURL: ossFuzzStatusURL,
projects: map[string]bool{},
}
c.once.Do(func() {
c.init()
})
if c.err != nil {
return nil, c.err
}
return &c, nil
}
// Search implements RepoClient.Search.
func (c *client) Search(request clients.SearchRequest) (clients.SearchResponse, error) {
c.once.Do(func() {
c.init()
})
var sr clients.SearchResponse
if c.err != nil {
return sr, c.err
}
projectURI := strings.ToLower(request.Query)
if c.projects[projectURI] {
sr.Hits = 1
}
return sr, nil
}
func (c *client) init() {
b, err := fetchStatusFile(c.ctx, c.statusURL)
if err != nil {
c.err = err
return
}
if err = parseStatusFile(b, c.projects); err != nil {
c.err = err
return
}
}
func parseStatusFile(contents []byte, m map[string]bool) error {
status := ossFuzzStatus{}
if err := json.Unmarshal(contents, &status); err != nil {
return fmt.Errorf("parse status file: %w", err)
}
for i := range status.Projects {
repoURI := status.Projects[i].RepoURI
normalizedRepoURI, err := normalize(repoURI)
if err != nil {
continue
}
m[normalizedRepoURI] = true
}
return nil
}
func fetchStatusFile(ctx context.Context, uri string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil)
if err != nil {
return nil, fmt.Errorf("making status file request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http.Get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s: %w", resp.Status, errUnreachableStatusFile)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("io.ReadAll: %w", err)
}
return b, nil
}
func normalize(rawURL string) (string, error) {
u, err := url.Parse(strings.ToLower(rawURL))
if err != nil {
return "", fmt.Errorf("url.Parse: %w", err)
}
const splitLen = 3 // corresponding to owner/repo/rest
const minLen = 2 // corresponds to owner/repo
split := strings.SplitN(strings.Trim(u.Path, "/"), "/", splitLen)
if len(split) < minLen {
return "", fmt.Errorf("%s: %w", rawURL, errMalformedURL)
}
org := split[0]
repo := strings.TrimSuffix(split[1], ".git")
return fmt.Sprintf("%s/%s/%s", u.Host, org, repo), nil
}
// URI implements RepoClient.URI.
func (c *client) URI() string {
return c.statusURL
}
// InitRepo implements RepoClient.InitRepo.
func (c *client) InitRepo(inputRepo clients.Repo, commitSHA string, commitDepth int) error {
return fmt.Errorf("InitRepo: %w", clients.ErrUnsupportedFeature)
}
// IsArchived implements RepoClient.IsArchived.
func (c *client) IsArchived() (bool, error) {
return false, fmt.Errorf("IsArchived: %w", clients.ErrUnsupportedFeature)
}
// LocalPath implements RepoClient.LocalPath.
func (c *client) LocalPath() (string, error) {
return "", fmt.Errorf("LocalPath: %w", clients.ErrUnsupportedFeature)
}
// ListFiles implements RepoClient.ListFiles.
func (c *client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
return nil, fmt.Errorf("ListFiles: %w", clients.ErrUnsupportedFeature)
}
// GetFileReader implements RepoClient.GetFileReader.
func (c *client) GetFileReader(filename string) (io.ReadCloser, error) {
return nil, fmt.Errorf("GetFileReader: %w", clients.ErrUnsupportedFeature)
}
// GetBranch implements RepoClient.GetBranch.
func (c *client) GetBranch(branch string) (*clients.BranchRef, error) {
return nil, fmt.Errorf("GetBranch: %w", clients.ErrUnsupportedFeature)
}
// GetDefaultBranch implements RepoClient.GetDefaultBranch.
func (c *client) GetDefaultBranch() (*clients.BranchRef, error) {
return nil, fmt.Errorf("GetDefaultBranch: %w", clients.ErrUnsupportedFeature)
}
// GetOrgRepoClient implements RepoClient.GetOrgRepoClient.
func (c *client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
return nil, fmt.Errorf("GetOrgRepoClient: %w", clients.ErrUnsupportedFeature)
}
// GetDefaultBranchName implements RepoClient.GetDefaultBranchName.
func (c *client) GetDefaultBranchName() (string, error) {
return "", fmt.Errorf("GetDefaultBranchName: %w", clients.ErrUnsupportedFeature)
}
// ListCommits implements RepoClient.ListCommits.
func (c *client) ListCommits() ([]clients.Commit, error) {
return nil, fmt.Errorf("ListCommits: %w", clients.ErrUnsupportedFeature)
}
// ListIssues implements RepoClient.ListIssues.
func (c *client) ListIssues() ([]clients.Issue, error) {
return nil, fmt.Errorf("ListIssues: %w", clients.ErrUnsupportedFeature)
}
// ListReleases implements RepoClient.ListReleases.
func (c *client) ListReleases() ([]clients.Release, error) {
return nil, fmt.Errorf("ListReleases: %w", clients.ErrUnsupportedFeature)
}
// ListContributors implements RepoClient.ListContributors.
func (c *client) ListContributors() ([]clients.User, error) {
return nil, fmt.Errorf("ListContributors: %w", clients.ErrUnsupportedFeature)
}
// ListSuccessfulWorkflowRuns implements RepoClient.ListSuccessfulWorkflowRuns.
func (c *client) ListSuccessfulWorkflowRuns(filename string) ([]clients.WorkflowRun, error) {
return nil, fmt.Errorf("ListSuccessfulWorkflowRuns: %w", clients.ErrUnsupportedFeature)
}
// ListCheckRunsForRef implements RepoClient.ListCheckRunsForRef.
func (c *client) ListCheckRunsForRef(ref string) ([]clients.CheckRun, error) {
return nil, fmt.Errorf("ListCheckRunsForRef: %w", clients.ErrUnsupportedFeature)
}
// ListStatuses implements RepoClient.ListStatuses.
func (c *client) ListStatuses(ref string) ([]clients.Status, error) {
return nil, fmt.Errorf("ListStatuses: %w", clients.ErrUnsupportedFeature)
}
// ListWebhooks implements RepoClient.ListWebhooks.
func (c *client) ListWebhooks() ([]clients.Webhook, error) {
return nil, fmt.Errorf("ListWebhooks: %w", clients.ErrUnsupportedFeature)
}
// SearchCommits implements RepoClient.SearchCommits.
func (c *client) SearchCommits(request clients.SearchCommitsOptions) ([]clients.Commit, error) {
return nil, fmt.Errorf("SearchCommits: %w", clients.ErrUnsupportedFeature)
}
// Close implements RepoClient.Close.
func (c *client) Close() error {
return nil
}
// ListProgrammingLanguages implements RepoClient.ListProgrammingLanguages.
func (c *client) ListProgrammingLanguages() ([]clients.Language, error) {
return nil, fmt.Errorf("ListProgrammingLanguages: %w", clients.ErrUnsupportedFeature)
}
// ListLicenses implements RepoClient.ListLicenses.
func (c *client) ListLicenses() ([]clients.License, error) {
return nil, fmt.Errorf("ListLicenses: %w", clients.ErrUnsupportedFeature)
}
// GetCreatedAt implements RepoClient.GetCreatedAt.
func (c *client) GetCreatedAt() (time.Time, error) {
return time.Time{}, fmt.Errorf("GetCreatedAt: %w", clients.ErrUnsupportedFeature)
}
// Copyright 2021 OpenSSF 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 clients
import (
"context"
"errors"
"fmt"
"os"
"runtime/debug"
"github.com/google/osv-scanner/pkg/osvscanner"
sce "github.com/ossf/scorecard/v5/errors"
)
var _ VulnerabilitiesClient = osvClient{}
type osvClient struct {
local bool
}
// ListUnfixedVulnerabilities implements VulnerabilityClient.ListUnfixedVulnerabilities.
func (v osvClient) ListUnfixedVulnerabilities(
ctx context.Context,
commit,
localPath string,
) (_ VulnerabilitiesResponse, err error) {
defer func() {
if r := recover(); r != nil {
err = sce.CreateInternal(sce.ErrScorecardInternal, fmt.Sprintf("osv-scanner panic: %v", r))
fmt.Fprintf(os.Stderr, "osv-scanner panic: %v\n%s\n", r, string(debug.Stack()))
}
}()
directoryPaths := []string{}
if localPath != "" {
directoryPaths = append(directoryPaths, localPath)
}
gitCommits := []string{}
if commit != "" {
gitCommits = append(gitCommits, commit)
}
res, err := osvscanner.DoScan(osvscanner.ScannerActions{
DirectoryPaths: directoryPaths,
SkipGit: true,
Recursive: true,
GitCommits: gitCommits,
ExperimentalScannerActions: osvscanner.ExperimentalScannerActions{
CompareOffline: v.local,
DownloadDatabases: v.local,
},
}, nil) // TODO: Do logging?
response := VulnerabilitiesResponse{}
// either no vulns found, or no packages detected by osvscanner, which likely means no vulns
// while there could still be vulns, not detecting any packages shouldn't be a runtime error.
if err == nil || errors.Is(err, osvscanner.NoPackagesFoundErr) {
return response, nil
}
// If vulnerabilities are found, err will be set to osvscanner.VulnerabilitiesFoundErr
if errors.Is(err, osvscanner.VulnerabilitiesFoundErr) {
vulns := res.Flatten()
for i := range vulns {
// ignore Go stdlib vulns. The go directive from the go.mod isn't a perfect metric
// of which version of Go will be used to build a project.
if vulns[i].Package.Ecosystem == "Go" && vulns[i].Package.Name == "stdlib" {
continue
}
response.Vulnerabilities = append(response.Vulnerabilities, Vulnerability{
ID: vulns[i].Vulnerability.ID,
Aliases: vulns[i].Vulnerability.Aliases,
})
// Remove duplicate vulnerability IDs for now as we don't report information
// on the source of each vulnerability yet, therefore having multiple identical
// vuln IDs might be confusing.
response.Vulnerabilities = removeDuplicate(
response.Vulnerabilities,
func(key Vulnerability) string { return key.ID },
)
}
return response, nil
}
return VulnerabilitiesResponse{}, fmt.Errorf("osvscanner.DoScan: %w", err)
}
// RemoveDuplicate removes duplicate entries from a slice.
func removeDuplicate[T any, K comparable](sliceList []T, keyExtract func(T) K) []T {
allKeys := make(map[K]bool)
list := []T{}
for _, item := range sliceList {
key := keyExtract(item)
if _, value := allKeys[key]; !value {
allKeys[key] = true
list = append(list, item)
}
}
return list
}
// Copyright 2021 OpenSSF 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 clients
// User represents a Git user.
type User struct {
Login string
Companies []string
Organizations []User
NumContributions int
ID int64
IsBot bool
IsCodeOwner bool
}
// RepoAssociation is how a user is associated with a repository.
type RepoAssociation uint32
// Values taken from https://docs.github.com/en/graphql/reference/enums#commentauthorassociation.
// Additional values may be added in the future for non-GitHub projects.
// NOTE: Values are present in increasing order of privilege. If adding new values
// maintain the order of privilege to ensure Gte() functionality is preserved.
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
)
// Gte is >= comparator for RepoAssociation enum.
func (r RepoAssociation) Gte(val RepoAssociation) bool {
return r >= val
}
// String returns an string value for RepoAssociation enum.
func (r RepoAssociation) String() string {
switch r {
case RepoAssociationMannequin:
return "unknown"
case RepoAssociationNone:
return "none"
case RepoAssociationFirstTimer:
return "first-timer"
case RepoAssociationFirstTimeContributor:
return "first-time-contributor"
case RepoAssociationContributor:
return "contributor"
case RepoAssociationCollaborator:
return "collaborator"
case RepoAssociationMember:
return "member"
case RepoAssociationOwner:
return "owner"
default:
return ""
}
}
// Copyright 2021 OpenSSF 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 clients
import (
"context"
)
// VulnerabilitiesClient checks for vulnerabilities in vuln DB.
type VulnerabilitiesClient interface {
ListUnfixedVulnerabilities(
context context.Context,
commit string,
localDir string,
) (VulnerabilitiesResponse, error)
}
// DefaultVulnerabilitiesClient returns a new OSV Vulnerabilities client.
func DefaultVulnerabilitiesClient() VulnerabilitiesClient {
return osvClient{local: false}
}
// ExperimentalLocalOSVClient returns an OSV Vulnerabilities client which
// takes advantage of their experimental local database option. As the
// osv-scanner feature is experimental, so is our usage of it. This function
// may be removed without warning.
//
// https://google.github.io/osv-scanner/experimental/offline-mode/#local-database-option
func ExperimentalLocalOSVClient() VulnerabilitiesClient {
return osvClient{local: true}
}
// VulnerabilitiesResponse is the response from the vuln DB.
type VulnerabilitiesResponse struct {
Vulnerabilities []Vulnerability
}
// Vulnerability uniquely identifies a reported security vuln.
type Vulnerability struct {
ID string
Aliases []string
}
// Copyright 2024 OpenSSF 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 config
// Reason is the reason behind an annotation.
type Reason string
const (
// TestData is to annotate when a check or probe is targeting a danger
// in files or code snippets only used for test or example purposes.
TestData Reason = "test-data"
// Remediated is to annotate when a check or probe correctly identified a
// danger and, even though the danger is necessary, a remediation was already applied.
// E.g. a workflow is dangerous but only run under maintainers verification and approval,
// or a binary is needed but it is signed or has provenance.
Remediated Reason = "remediated"
// NotApplicable is to annotate when a check or probe is not applicable for the case.
// E.g. the dependencies should not be pinned because the project is a library.
NotApplicable Reason = "not-applicable"
// NotSupported is to annotate when the maintainer fulfills a check or probe in a way
// that is not supported by Scorecard. E.g. Clang-Tidy is used as SAST tool but not identified
// because its not supported.
NotSupported Reason = "not-supported"
// NotDetected is to annotate when the maintainer fulfills a check or probe in a way
// that is supported by Scorecard but not identified. E.g. Dependabot is configured in the
// repository settings and not in a file.
NotDetected Reason = "not-detected"
)
// ReasonGroup groups the annotation reason and, in the future, the related probe.
// If there is a probe, the reason applies to the probe.
// If there is not a probe, the reason applies to the check or checks in
// the group.
type ReasonGroup struct {
Reason Reason `yaml:"reason"`
}
// Annotation defines a group of checks that are being annotated for various reasons.
type Annotation struct {
Checks []string `yaml:"checks"`
Reasons []ReasonGroup `yaml:"reasons"`
}
// Doc maps a reason to its human-readable explanation.
func (r *Reason) Doc() string {
switch *r {
case TestData:
return "The files or code snippets are only used for test or example purposes."
case Remediated:
return "The dangerous files or code snippets are necessary but remediations were already applied."
case NotApplicable:
return "The check or probe is not applicable in this case."
case NotSupported:
return "The check or probe is fulfilled but in a way that is not supported by Scorecard."
case NotDetected:
return "The check or probe is fulfilled but in a way that is supported by Scorecard but it was not detected."
default:
return string(*r)
}
}
// isValidReason checks if a reason can be used by a config file.
func isValidReason(r Reason) bool {
// the reason must be one of the preselected options
switch r {
case TestData, Remediated, NotApplicable, NotSupported, NotDetected:
return true
default:
return false
}
}
// Copyright 2024 OpenSSF 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 config
import (
"errors"
"fmt"
"io"
"strings"
"gopkg.in/yaml.v3"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/internal/checknames"
)
var (
errInvalidCheck = errors.New("check is not valid")
errInvalidReason = errors.New("reason is not valid")
)
// Config contains configurations defined by maintainers.
type Config struct {
Annotations []Annotation `yaml:"annotations"`
}
// parseFile takes the scorecard.yml file content and returns a `Config`.
func parseFile(c *Config, content []byte) error {
unmarshalErr := yaml.Unmarshal(content, c)
if unmarshalErr != nil {
return sce.WithMessage(sce.ErrScorecardInternal, unmarshalErr.Error())
}
return nil
}
func isValidCheck(check string) bool {
for _, c := range checknames.AllValidChecks {
if strings.EqualFold(c, check) {
return true
}
}
return false
}
func validate(c Config) error {
for _, annotation := range c.Annotations {
for _, check := range annotation.Checks {
if !isValidCheck(check) {
return fmt.Errorf("%w: %s", errInvalidCheck, check)
}
}
for _, reasonGroup := range annotation.Reasons {
if !isValidReason(reasonGroup.Reason) {
return fmt.Errorf("%w: %s", errInvalidReason, reasonGroup.Reason)
}
}
}
return nil
}
// Parse reads the configuration file from the repo, stored in scorecard.yml, and returns a `Config`.
func Parse(r io.Reader) (Config, error) {
c := Config{}
// Find scorecard.yml file in the repository's root
content, err := io.ReadAll(r)
if err != nil {
return Config{}, fmt.Errorf("fail to read configuration file: %w", err)
}
err = parseFile(&c, content)
if err != nil {
return Config{}, fmt.Errorf("fail to parse configuration file: %w", err)
}
err = validate(c)
if err != nil {
return Config{}, fmt.Errorf("configuration file is not valid: %w", err)
}
// Return configuration
return c, nil
}
// Copyright 2021 OpenSSF 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 errors
// CreateInternal creates internal error, not using
// any of the errors listed in public.go.
func CreateInternal(e error, msg string) error {
return WithMessage(e, msg)
}
// Copyright 2021 OpenSSF 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 errors
import (
"errors"
"fmt"
)
var (
// some of these errors didn't follow naming conventions when they were introduced.
// for backward compatibility reasons, they can't be changed and have nolint directives.
// ErrScorecardInternal indicates a runtime error in Scorecard code.
ErrScorecardInternal = errors.New("internal error")
// ErrRepoUnreachable indicates Scorecard is unable to establish connection with the repository.
ErrRepoUnreachable = errors.New("repo unreachable")
// ErrUnsupportedHost indicates the repo's host is unsupported.
ErrUnsupportedHost = errors.New("unsupported host")
// ErrInvalidURL indicates the repo's full URL was not passed.
ErrInvalidURL = errors.New("invalid repo flag")
// ErrShellParsing indicates there was an error when parsing shell code.
ErrShellParsing = errors.New("error parsing shell code")
// ErrJobOSParsing indicates there was an error when detecting a job's operating system.
ErrJobOSParsing = errors.New("error parsing job operating system")
// ErrUnsupportedCheck indicates check cannot be run for given request.
ErrUnsupportedCheck = errors.New("check is not supported for this request")
// ErrCheckRuntime indicates an individual check had a runtime error.
ErrCheckRuntime = errors.New("check runtime error")
)
// WithMessage wraps any of the errors listed above.
// For examples, see errors/errors.md.
func WithMessage(e error, msg string) error {
// Note: Errorf automatically wraps the error when used with `%w`.
if len(msg) > 0 {
return fmt.Errorf("%w: %v", e, msg)
}
// We still need to use %w to prevent callers from using e == ErrInvalidDockerFile.
return fmt.Errorf("%w", e)
}
// GetName returns the name of the error.
func GetName(err error) string {
switch {
case errors.Is(err, ErrScorecardInternal):
return "ErrScorecardInternal"
case errors.Is(err, ErrRepoUnreachable):
return "ErrRepoUnreachable"
case errors.Is(err, ErrShellParsing):
return "ErrShellParsing"
default:
return "ErrUnknown"
}
}
// Copyright 2023 OpenSSF 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 finding
import (
"embed"
"errors"
"fmt"
"reflect"
"strings"
"gopkg.in/yaml.v3"
)
// FileType is the type of a file.
type FileType int
const (
// FileTypeNone must be `0`.
FileTypeNone FileType = iota
// FileTypeSource is for source code files.
FileTypeSource
// FileTypeBinary is for binary files.
FileTypeBinary
// FileTypeText is for text files.
FileTypeText
// FileTypeURL for URLs.
FileTypeURL
// FileTypeBinaryVerified for verified binary files.
FileTypeBinaryVerified
)
// Location represents the location of a finding.
type Location struct {
LineStart *uint `json:"lineStart,omitempty"`
LineEnd *uint `json:"lineEnd,omitempty"`
Snippet *string `json:"snippet,omitempty"`
Path string `json:"path"`
Type FileType `json:"type"`
}
// Outcome is the result of a finding.
type Outcome string
// TODO(#2928): re-visit the finding definitions.
const (
// OutcomeFalse indicates the answer to the probe's question is "false" or "no".
OutcomeFalse Outcome = "False"
// OutcomeNotAvailable indicates an unavailable outcome,
// typically because an API call did not return an answer.
OutcomeNotAvailable Outcome = "NotAvailable"
// OutcomeError indicates an errors while running.
// The results could not be determined.
OutcomeError Outcome = "Error"
// OutcomeTrue indicates the answer to the probe's question is "true" or "yes".
OutcomeTrue Outcome = "True"
// OutcomeNotSupported indicates a non-supported outcome.
OutcomeNotSupported Outcome = "NotSupported"
// OutcomeNotApplicable indicates if a finding should not
// be considered in evaluation.
OutcomeNotApplicable Outcome = "NotApplicable"
)
// Finding represents a finding.
type Finding struct {
Location *Location `json:"location,omitempty"`
Remediation *Remediation `json:"remediation,omitempty"`
Values map[string]string `json:"values,omitempty"`
Probe string `json:"probe"`
Message string `json:"message"`
Outcome Outcome `json:"outcome"`
// Expected bad outcome, used to determine if Remediation should be set
badOutcome Outcome
}
// AnonymousFinding is a finding without a corresponding probe ID.
type AnonymousFinding struct {
Probe string `json:"probe,omitempty"`
Finding
}
var errInvalid = errors.New("invalid")
// FromBytes creates a finding for a probe given its config file's content.
func FromBytes(content []byte, probeID string) (*Finding, error) {
p, err := probeFromBytes(content, probeID)
if err != nil {
return nil, err
}
f := &Finding{
Probe: p.ID,
Outcome: OutcomeFalse,
Remediation: p.Remediation,
badOutcome: p.RemediateOnOutcome,
}
return f, nil
}
// New creates a new finding.
func New(loc embed.FS, probeID string) (*Finding, error) {
p, err := newProbe(loc, probeID)
if err != nil {
return nil, err
}
f := &Finding{
Probe: p.ID,
Outcome: OutcomeFalse,
Remediation: p.Remediation,
badOutcome: p.RemediateOnOutcome,
}
return f, nil
}
// NewWith create a finding with the desired location and outcome.
func NewWith(efs embed.FS, probeID, text string, loc *Location,
o Outcome,
) (*Finding, error) {
f, err := New(efs, probeID)
if err != nil {
return nil, fmt.Errorf("finding.New: %w", err)
}
f = f.WithMessage(text).WithOutcome(o).WithLocation(loc)
return f, nil
}
// NewFalse create a false finding with the desired location.
func NewFalse(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeFalse)
}
// NewNotApplicable create a finding with a NotApplicable outcome and the desired location.
func NewNotApplicable(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeNotApplicable)
}
// NewNotAvailable create a finding with a NotAvailable outcome and the desired location.
func NewNotAvailable(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeNotAvailable)
}
// NewNotSupported create a finding with a NotSupported outcome and the desired location.
func NewNotSupported(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeNotSupported)
}
// NewTrue create a true finding with the desired location.
func NewTrue(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeTrue)
}
// Anonymize removes the probe ID and outcome
// from the finding. It is a temporary solution
// to integrate the code in the details without exposing
// too much information.
func (f *Finding) Anonymize() *AnonymousFinding {
return &AnonymousFinding{Finding: *f}
}
// WithMessage adds a message to an existing finding.
// No copy is made.
func (f *Finding) WithMessage(text string) *Finding {
f.Message = text
return f
}
// UniqueProbesEqual checks the probe names present in a list of findings
// and compare them against an expected list.
func UniqueProbesEqual(findings []Finding, probes []string) bool {
// Collect unique probes from findings.
fm := make(map[string]bool)
for i := range findings {
f := &findings[i]
fm[f.Probe] = true
}
// Collect probes from list.
pm := make(map[string]bool)
for i := range probes {
p := &probes[i]
pm[*p] = true
}
return reflect.DeepEqual(pm, fm)
}
// WithLocation adds a location to an existing finding.
// No copy is made.
func (f *Finding) WithLocation(loc *Location) *Finding {
f.Location = loc
if f.Remediation != nil && f.Location != nil {
// Replace location data.
f.Remediation.Text = strings.ReplaceAll(f.Remediation.Text,
"${{ finding.location.path }}", f.Location.Path)
f.Remediation.Markdown = strings.ReplaceAll(f.Remediation.Markdown,
"${{ finding.location.path }}", f.Location.Path)
}
return f
}
// WithValues sets the values to an existing finding.
// No copy is made.
func (f *Finding) WithValues(values map[string]string) *Finding {
f.Values = values
return f
}
// WithPatch adds a patch to an existing finding.
// No copy is made.
func (f *Finding) WithPatch(patch *string) *Finding {
f.Remediation.Patch = patch
// NOTE: we will update the remediation section
// using patch information, e.g. ${{ patch.content }}.
return f
}
// WithOutcome adds an outcome to an existing finding.
// No copy is made.
func (f *Finding) WithOutcome(o Outcome) *Finding {
f.Outcome = o
// only bad outcomes need remediation, clear if unneeded
if o != f.badOutcome {
f.Remediation = nil
}
return f
}
// WithRemediationMetadata adds remediation metadata to an existing finding.
// No copy is made.
func (f *Finding) WithRemediationMetadata(values map[string]string) *Finding {
if f.Remediation != nil {
// Replace all dynamic values.
for k, v := range values {
// Replace metadata.
f.Remediation.Text = strings.ReplaceAll(f.Remediation.Text,
fmt.Sprintf("${{ metadata.%s }}", k), v)
f.Remediation.Markdown = strings.ReplaceAll(f.Remediation.Markdown,
fmt.Sprintf("${{ metadata.%s }}", k), v)
}
}
return f
}
// WithValue adds a value to f.Values.
// No copy is made.
func (f *Finding) WithValue(k, v string) *Finding {
if f.Values == nil {
f.Values = make(map[string]string)
}
f.Values[k] = v
return f
}
// UnmarshalYAML is a custom unmarshalling function
// to transform the string into an enum.
func (o *Outcome) UnmarshalYAML(n *yaml.Node) error {
var str string
if err := n.Decode(&str); err != nil {
return fmt.Errorf("decode: %w", err)
}
switch n.Value {
case "False":
*o = OutcomeFalse
case "True":
*o = OutcomeTrue
case "NotAvailable":
*o = OutcomeNotAvailable
case "NotSupported":
*o = OutcomeNotSupported
case "NotApplicable":
*o = OutcomeNotApplicable
case "Error":
*o = OutcomeError
default:
return fmt.Errorf("%w: %q", errInvalid, str)
}
return nil
}
// Copyright 2023 OpenSSF 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 finding
import (
"embed"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
"github.com/ossf/scorecard/v5/clients"
pyaml "github.com/ossf/scorecard/v5/internal/probes/yaml"
)
// RemediationEffort indicates the estimated effort necessary to remediate a finding.
type RemediationEffort int
// lifecycle indicates the probe's stability.
type lifecycle string
const (
// RemediationEffortNone indicates a no remediation effort.
RemediationEffortNone RemediationEffort = iota
// RemediationEffortLow indicates a low remediation effort.
RemediationEffortLow
// RemediationEffortMedium indicates a medium remediation effort.
RemediationEffortMedium
// RemediationEffortHigh indicates a high remediation effort.
RemediationEffortHigh
lifecycleExperimental lifecycle = "experimental"
lifecycleStable lifecycle = "stable"
lifecycleDeprecated lifecycle = "deprecated"
)
// Remediation represents the remediation for a finding.
type Remediation struct {
// Patch for machines.
Patch *string `json:"patch,omitempty"`
// Text for humans.
Text string `json:"text"`
// Text in markdown format for humans.
Markdown string `json:"markdown"`
// Effort to remediate.
Effort RemediationEffort `json:"effort"`
}
var supportedClients = map[string]bool{
"github": true,
"gitlab": true,
"localdir": true,
}
type probe struct {
ID string
Short string
Motivation string
Implementation string
Remediation *Remediation
RemediateOnOutcome Outcome
}
func probeFromBytes(content []byte, probeID string) (*probe, error) {
r, err := parseFromYAML(content)
if err != nil {
return nil, err
}
if err := validate(r, probeID); err != nil {
return nil, err
}
return &probe{
ID: r.ID,
Short: r.Short,
Motivation: r.Motivation,
Implementation: r.Implementation,
Remediation: &Remediation{
Text: strings.Join(r.Remediation.Text, "\n"),
Markdown: strings.Join(r.Remediation.Markdown, "\n"),
Effort: toRemediationEffort(r.Remediation.Effort),
},
RemediateOnOutcome: Outcome(r.Remediation.OnOutcome),
}, nil
}
// New create a new probe.
func newProbe(loc embed.FS, probeID string) (*probe, error) {
content, err := os.ReadFile(fmt.Sprintf("/tmp/probedefinitions/%s/def.yml", probeID))
if err != nil {
return nil, fmt.Errorf("%w", err)
}
return probeFromBytes(content, probeID)
}
func validate(r *pyaml.Probe, probeID string) error {
if err := validateID(r.ID, probeID); err != nil {
return err
}
if err := validateRemediation(&r.Remediation); err != nil {
return err
}
if err := validateEcosystem(r.Ecosystem); err != nil {
return err
}
if err := validateLifecycle(lifecycle(r.Lifecycle)); err != nil {
return err
}
return nil
}
func validateID(actual, expected string) error {
if actual != expected {
return fmt.Errorf("%w: ID: read '%v', expected '%v'", errInvalid,
actual, expected)
}
return nil
}
func validateRemediation(r *pyaml.Remediation) error {
if err := validateRemediationOutcomeTrigger(Outcome(r.OnOutcome)); err != nil {
return fmt.Errorf("remediation: %w", err)
}
switch toRemediationEffort(r.Effort) {
case RemediationEffortHigh, RemediationEffortMedium, RemediationEffortLow:
return nil
default:
return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("remediation '%v'", r))
}
}
func validateEcosystem(r pyaml.Ecosystem) error {
if err := validateSupportedLanguages(r); err != nil {
return err
}
if err := validateSupportedClients(r); err != nil {
return err
}
return nil
}
func validateRemediationOutcomeTrigger(o Outcome) error {
switch o {
case OutcomeTrue, OutcomeFalse, OutcomeNotApplicable, OutcomeNotAvailable, OutcomeNotSupported, OutcomeError:
return nil
default:
return fmt.Errorf("%w: unknown outcome: %v", errInvalid, o)
}
}
func validateSupportedLanguages(r pyaml.Ecosystem) error {
for _, lang := range r.Languages {
switch clients.LanguageName(lang) {
case clients.Go, clients.Python, clients.JavaScript,
clients.Cpp, clients.C, clients.TypeScript,
clients.Java, clients.CSharp, clients.Ruby,
clients.PHP, clients.StarLark, clients.Scala,
clients.Kotlin, clients.Swift, clients.Rust,
clients.Haskell, clients.All, clients.Dockerfile,
clients.ObjectiveC:
continue
default:
return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("language '%v'", r))
}
}
return nil
}
func validateSupportedClients(r pyaml.Ecosystem) error {
for _, lang := range r.Clients {
if _, ok := supportedClients[lang]; !ok {
return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("client '%v'", r))
}
}
return nil
}
func validateLifecycle(l lifecycle) error {
switch l {
case lifecycleExperimental, lifecycleStable, lifecycleDeprecated:
return nil
default:
return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("lifecycle '%v'", l))
}
}
func parseFromYAML(content []byte) (*pyaml.Probe, error) {
r := pyaml.Probe{}
err := yaml.Unmarshal(content, &r)
if err != nil {
return nil, fmt.Errorf("unable to parse yaml: %w", err)
}
return &r, nil
}
// UnmarshalYAML is a custom unmarshalling function
// to transform the string into an enum.
func (r *RemediationEffort) UnmarshalYAML(n *yaml.Node) error {
var str string
if err := n.Decode(&str); err != nil {
return fmt.Errorf("%w: %w", errInvalid, err)
}
*r = toRemediationEffort(n.Value)
if *r == RemediationEffortNone {
return fmt.Errorf("%w: effort:%q", errInvalid, str)
}
return nil
}
// String stringifies the enum.
func (r *RemediationEffort) String() string {
switch *r {
case RemediationEffortLow:
return "Low"
case RemediationEffortMedium:
return "Medium"
case RemediationEffortHigh:
return "High"
default:
return ""
}
}
func toRemediationEffort(s string) RemediationEffort {
switch s {
case "Low":
return RemediationEffortLow
case "Medium":
return RemediationEffortMedium
case "High":
return RemediationEffortHigh
default:
return RemediationEffortNone
}
}
// Copyright 2024 OpenSSF 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 csproj
import (
"encoding/xml"
"errors"
)
var errInvalidCsProjFile = errors.New("error parsing csproj file")
type PropertyGroup struct {
XMLName xml.Name `xml:"PropertyGroup"`
RestoreLockedMode bool `xml:"RestoreLockedMode"`
AllowUnsafeBlocks bool `xml:"AllowUnsafeBlocks"`
}
type Project struct {
XMLName xml.Name `xml:"Project"`
PropertyGroups []PropertyGroup `xml:"PropertyGroup"`
}
func IsRestoreLockedModeEnabled(content []byte) (bool, error) {
return isCsProjFilePropertyGroupEnabled(content, func(propertyGroup *PropertyGroup) bool {
return propertyGroup.RestoreLockedMode
})
}
func IsAllowUnsafeBlocksEnabled(content []byte) (bool, error) {
return isCsProjFilePropertyGroupEnabled(content, func(propertyGroup *PropertyGroup) bool {
return propertyGroup.AllowUnsafeBlocks
})
}
func isCsProjFilePropertyGroupEnabled(content []byte, predicate func(*PropertyGroup) bool) (bool, error) {
var project Project
err := xml.Unmarshal(content, &project)
if err != nil {
return false, errInvalidCsProjFile
}
for _, propertyGroup := range project.PropertyGroups {
if predicate(&propertyGroup) {
return true, nil
}
}
return false, nil
}
// Copyright 2024 OpenSSF 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 properties
import (
"encoding/xml"
"errors"
"regexp"
)
var errInvalidPropsFile = errors.New("error parsing dotnet props file")
type CPMPropertyGroup struct {
XMLName xml.Name `xml:"PropertyGroup"`
ManagePackageVersionsCentrally bool `xml:"ManagePackageVersionsCentrally"`
}
type PackageVersionItemGroup struct {
XMLName xml.Name `xml:"ItemGroup"`
PackageVersion []packageVersion `xml:"PackageVersion"`
}
type packageVersion struct {
XMLName xml.Name `xml:"PackageVersion"`
Version string `xml:"Version,attr"`
Include string `xml:"Include,attr"`
}
type DirectoryPropsProject struct {
XMLName xml.Name `xml:"Project"`
PropertyGroups []CPMPropertyGroup `xml:"PropertyGroup"`
ItemGroups []PackageVersionItemGroup `xml:"ItemGroup"`
}
type NugetPackage struct {
Name string
Version string
IsFixed bool
}
type CentralPackageManagementConfig struct {
PackageVersions []NugetPackage
IsCPMEnabled bool
}
func GetCentralPackageManagementConfig(path string, content []byte) (CentralPackageManagementConfig, error) {
var project DirectoryPropsProject
err := xml.Unmarshal(content, &project)
if err != nil {
return CentralPackageManagementConfig{}, errInvalidPropsFile
}
cpmConfig := CentralPackageManagementConfig{
IsCPMEnabled: isCentralPackageManagementEnabled(&project),
}
if cpmConfig.IsCPMEnabled {
cpmConfig.PackageVersions = extractNugetPackages(&project)
}
return cpmConfig, nil
}
func isCentralPackageManagementEnabled(project *DirectoryPropsProject) bool {
for _, propertyGroup := range project.PropertyGroups {
if propertyGroup.ManagePackageVersionsCentrally {
return true
}
}
return false
}
func extractNugetPackages(project *DirectoryPropsProject) []NugetPackage {
var nugetPackages []NugetPackage
for _, itemGroup := range project.ItemGroups {
for _, packageVersion := range itemGroup.PackageVersion {
nugetPackages = append(nugetPackages, NugetPackage{
Name: packageVersion.Include,
Version: packageVersion.Version,
IsFixed: isValidFixedVersion(packageVersion.Version),
})
}
}
return nugetPackages
}
// isValidFixedVersion checks if the version string is a valid, fixed version.
// more on version numbers here: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning?tabs=semver20sort
// ^: Ensures the match starts at the beginning of the string.
// \[: Matches the opening square bracket [.
// [^\[,]+: Matches one or more characters that are not a comma (,) or a square bracket ([) (to avoid nested brackets).
// \]: Matches the closing square bracket ].
// $: Ensures the match ends at the end of the string.
func isValidFixedVersion(version string) bool {
pattern := `^\[[^\[,]+\]$`
re := regexp.MustCompile(pattern)
return re.MatchString(version)
}
// Copyright 2025 OpenSSF 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 gitfile defines functionality to list and fetch files after temporarily cloning a git repo.
package gitfile
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/ossf/scorecard/v5/clients"
)
var errPathTraversal = errors.New("requested file outside repo")
const repoDir = "repo*"
type Handler struct {
errSetup error
ctx context.Context
once *sync.Once
cloneURL string
gitRepo *git.Repository
tempDir string
commitSHA string
files []string
}
func (h *Handler) Init(ctx context.Context, cloneURL, commitSHA string) {
h.errSetup = nil
h.once = new(sync.Once)
h.ctx = ctx
h.cloneURL = cloneURL
h.commitSHA = commitSHA
h.files = nil
}
func (h *Handler) setup() error {
h.once.Do(func() {
tempDir, err := os.MkdirTemp("", repoDir)
if err != nil {
h.errSetup = err
return
}
h.tempDir = tempDir
h.gitRepo, err = git.PlainClone(h.tempDir, false, &git.CloneOptions{
URL: h.cloneURL,
// TODO: auth may be required for private repos
Depth: 1, // currently only use the git repo for files, dont need history
SingleBranch: true,
// https://github.com/go-git/go-git/issues/545#issuecomment-1353681676
Tags: git.NoTags,
})
if err != nil {
h.errSetup = err
return
}
// assume the commit SHA is reachable from the default branch
// this isn't as flexible as the tarball handler, but good enough for now
if h.commitSHA != clients.HeadSHA {
wt, err := h.gitRepo.Worktree()
if err != nil {
h.errSetup = err
return
}
if err := wt.Checkout(&git.CheckoutOptions{Hash: plumbing.NewHash(h.commitSHA)}); err != nil {
h.errSetup = fmt.Errorf("checkout specified commit: %w", err)
return
}
}
// go-git is not thread-safe so list the files inside this sync.Once and save them
// https://github.com/go-git/go-git/issues/773
files, err := enumerateFiles(h.gitRepo)
if err != nil {
h.errSetup = err
return
}
h.files = files
})
return h.errSetup
}
func (h *Handler) GetLocalPath() (string, error) {
if err := h.setup(); err != nil {
return "", fmt.Errorf("setup: %w", err)
}
return h.tempDir, nil
}
func (h *Handler) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
if err := h.setup(); err != nil {
return nil, fmt.Errorf("setup: %w", err)
}
var files []string
for _, f := range h.files {
shouldInclude, err := predicate(f)
if err != nil {
return nil, fmt.Errorf("error applying predicate to file %s: %w", f, err)
}
if shouldInclude {
files = append(files, f)
}
}
return files, nil
}
func enumerateFiles(repo *git.Repository) ([]string, error) {
ref, err := repo.Head()
if err != nil {
return nil, fmt.Errorf("git.Head: %w", err)
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
return nil, fmt.Errorf("git.CommitObject: %w", err)
}
tree, err := commit.Tree()
if err != nil {
return nil, fmt.Errorf("git.Commit.Tree: %w", err)
}
var files []string
err = tree.Files().ForEach(func(f *object.File) error {
files = append(files, f.Name)
return nil
})
if err != nil {
return nil, fmt.Errorf("git.Tree.Files: %w", err)
}
return files, nil
}
func (h *Handler) GetFile(filename string) (*os.File, error) {
if err := h.setup(); err != nil {
return nil, fmt.Errorf("setup: %w", err)
}
// check for path traversal
path := filepath.Join(h.tempDir, filename)
if !strings.HasPrefix(path, filepath.Clean(h.tempDir)+string(os.PathSeparator)) {
return nil, errPathTraversal
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
return f, nil
}
func (h *Handler) Cleanup() error {
if err := os.RemoveAll(h.tempDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("os.Remove: %w", err)
}
return nil
}
// Copyright 2024 OpenSSF 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 packageclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)
// This interface lets Scorecard look up package manager metadata for a project.
type ProjectPackageClient interface {
GetProjectPackageVersions(ctx context.Context, host, project string) (*ProjectPackageVersions, error)
}
type depsDevClient struct {
client *http.Client
}
type ProjectPackageVersions struct {
// field alignment
//nolint:govet
Versions []struct {
VersionKey struct {
System string `json:"system"`
Name string `json:"name"`
Version string `json:"version"`
} `json:"versionKey"`
SLSAProvenances []struct {
SourceRepository string `json:"sourceRepository"`
Commit string `json:"commit"`
Verified bool `json:"verified"`
} `json:"slsaProvenances"`
RelationType string `json:"relationType"`
RelationProvenance string `json:"relationProvenance"`
} `json:"versions"`
}
func CreateDepsDevClient() ProjectPackageClient {
return depsDevClient{
client: &http.Client{},
}
}
var (
ErrDepsDevAPI = errors.New("deps.dev")
ErrProjNotFoundInDepsDev = errors.New("project not found in deps.dev")
)
func (d depsDevClient) GetProjectPackageVersions(
ctx context.Context, host, project string,
) (*ProjectPackageVersions, error) {
path := fmt.Sprintf("%s/%s", host, project)
query := fmt.Sprintf("https://api.deps.dev/v3/projects/%s:packageversions", url.QueryEscape(path))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, query, nil)
if err != nil {
return nil, fmt.Errorf("http.NewRequestWithContext: %w", err)
}
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("deps.dev GetProjectPackageVersions: %w", err)
}
defer resp.Body.Close()
var res ProjectPackageVersions
if resp.StatusCode == http.StatusNotFound {
return nil, ErrProjNotFoundInDepsDev
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %s", ErrDepsDevAPI, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("resp.Body.Read: %w", err)
}
err = json.Unmarshal(body, &res)
if err != nil {
return nil, fmt.Errorf("deps.dev json.Unmarshal: %w", err)
}
return &res, nil
}
// Copyright 2024 OpenSSF 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 probes
import (
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
)
type Probe struct {
Name string
Implementation ProbeImpl
IndependentImplementation IndependentProbeImpl
RequiredRawData []checknames.CheckName
}
type ProbeImpl func(*checker.RawResults) ([]finding.Finding, string, error)
type IndependentProbeImpl func(*checker.CheckRequest) ([]finding.Finding, string, error)
// registered is the mapping of all registered probes.
var registered = map[string]Probe{}
func MustRegister(name string, impl ProbeImpl, requiredRawData []checknames.CheckName) {
err := register(Probe{
Name: name,
Implementation: impl,
RequiredRawData: requiredRawData,
})
if err != nil {
panic(err)
}
}
func MustRegisterIndependent(name string, impl IndependentProbeImpl) {
err := register(Probe{
Name: name,
IndependentImplementation: impl,
})
if err != nil {
panic(err)
}
}
func register(p Probe) error {
if p.Name == "" {
return errors.WithMessage(errors.ErrScorecardInternal, "name cannot be empty")
}
if p.Implementation == nil && p.IndependentImplementation == nil {
return errors.WithMessage(errors.ErrScorecardInternal, "at least one implementation must be non-nil")
}
if p.Implementation != nil && len(p.RequiredRawData) == 0 {
return errors.WithMessage(errors.ErrScorecardInternal, "non-independent probes need some raw data")
}
registered[p.Name] = p
return nil
}
func Get(name string) (Probe, error) {
p, ok := registered[name]
if !ok {
msg := fmt.Sprintf("probe %q not found", name)
return Probe{}, errors.WithMessage(errors.ErrScorecardInternal, msg)
}
return p, nil
}
// Copyright 2022 OpenSSF 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 log
import (
"log"
"strings"
"github.com/bombsimon/logrusr/v2"
"github.com/go-logr/logr"
"github.com/sirupsen/logrus"
)
// Logger exposes logging capabilities using
// https://pkg.go.dev/github.com/go-logr/logr.
type Logger struct {
*logr.Logger
}
// NewLogger creates an instance of *Logger.
// TODO(log): Consider adopting production config from zap.
func NewLogger(logLevel Level) *Logger {
logrusLog := logrus.New()
// Set log level from logrus
logrusLevel := parseLogrusLevel(logLevel)
logrusLog.SetLevel(logrusLevel)
return NewLogrusLogger(logrusLog)
}
// NewCronLogger creates an instance of *Logger.
func NewCronLogger(logLevel Level) *Logger {
logrusLog := logrus.New()
// for stackdriver, see: https://cloud.google.com/logging/docs/structured-logging#special-payload-fields
logrusLog.SetFormatter(&logrus.JSONFormatter{FieldMap: logrus.FieldMap{
logrus.FieldKeyLevel: "severity",
logrus.FieldKeyMsg: "message",
}})
// Set log level from logrus
logrusLevel := parseLogrusLevel(logLevel)
logrusLog.SetLevel(logrusLevel)
return NewLogrusLogger(logrusLog)
}
// NewLogrusLogger creates an instance of *Logger backed by the supplied
// logrusLog instance.
func NewLogrusLogger(logrusLog *logrus.Logger) *Logger {
logrLogger := logrusr.New(logrusLog)
logger := &Logger{
&logrLogger,
}
return logger
}
// ParseLevel takes a string level and returns the sclog Level constant.
// If the level is not recognized, it defaults to `sclog.InfoLevel` to swallow
// potential configuration errors/typos when specifying log levels.
// https://pkg.go.dev/github.com/sirupsen/logrus#ParseLevel
func ParseLevel(lvl string) Level {
switch strings.ToLower(lvl) {
case "panic":
return PanicLevel
case "fatal":
return FatalLevel
case "error":
return ErrorLevel
case "warn":
return WarnLevel
case "info":
return InfoLevel
case "debug":
return DebugLevel
case "trace":
return TraceLevel
}
return DefaultLevel
}
// Level is a string representation of log level, which can easily be passed as
// a parameter, in lieu of defined types in upstream logging packages.
type Level string
// Log levels.
const (
DefaultLevel = InfoLevel
TraceLevel Level = "trace"
DebugLevel Level = "debug"
InfoLevel Level = "info"
WarnLevel Level = "warn"
ErrorLevel Level = "error"
PanicLevel Level = "panic"
FatalLevel Level = "fatal"
)
func (l Level) String() string {
return string(l)
}
func parseLogrusLevel(lvl Level) logrus.Level {
logrusLevel, err := logrus.ParseLevel(lvl.String())
if err != nil {
log.Printf(
"defaulting to INFO log level, as %s is not a valid log level: %+v",
lvl,
err,
)
logrusLevel = logrus.InfoLevel
}
return logrusLevel
}
// Copyright 2021 OpenSSF 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 policy
import (
"errors"
"fmt"
"log"
"os"
"strings"
"gopkg.in/yaml.v3"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks"
sce "github.com/ossf/scorecard/v5/errors"
)
var (
errInvalidVersion = errors.New("invalid version")
errInvalidCheck = errors.New("invalid check name")
errInvalidScore = errors.New("invalid score")
errInvalidMode = errors.New("invalid mode")
errRepeatingCheck = errors.New("check has multiple definitions")
)
var allowedVersions = map[int]bool{1: true}
var modes = map[string]bool{"enforced": true, "disabled": true}
type checkPolicy struct {
Mode string `yaml:"mode"`
Score int `yaml:"score"`
}
type scorecardPolicy struct {
Policies map[string]checkPolicy `yaml:"policies"`
Version int `yaml:"version"`
}
func isAllowedVersion(v int) bool {
_, exists := allowedVersions[v]
return exists
}
func modeToProto(m string) CheckPolicy_Mode {
switch m {
default:
panic("will never happen")
case "enforced":
return CheckPolicy_ENFORCED
case "disabled":
return CheckPolicy_DISABLED
}
}
// ParseFromFile takes a policy file and returns a `ScorecardPolicy`.
func ParseFromFile(policyFile string) (*ScorecardPolicy, error) {
if policyFile != "" {
data, err := os.ReadFile(policyFile)
if err != nil {
return nil, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("os.ReadFile: %v", err))
}
sp, err := parseFromYAML(data)
if err != nil {
return nil,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("spol.ParseFromYAML: %v", err))
}
return sp, nil
}
return nil, nil
}
// parseFromYAML parses a policy file and returns a `ScorecardPolicy`.
func parseFromYAML(b []byte) (*ScorecardPolicy, error) {
// Internal golang for unmarshalling the policy file.
sp := scorecardPolicy{}
// Protobuf-defined policy (policy.proto and policy.pb.go).
retPolicy := ScorecardPolicy{Policies: map[string]*CheckPolicy{}}
err := yaml.Unmarshal(b, &sp)
if err != nil {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
if !isAllowedVersion(sp.Version) {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, errInvalidVersion.Error())
}
// Set version.
retPolicy.Version = int32(sp.Version)
checksFound := make(map[string]bool)
allChecks := checks.GetAllWithExperimental()
for n, p := range sp.Policies {
if _, exists := allChecks[n]; !exists {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInvalidCheck.Error(), n))
}
_, exists := modes[p.Mode]
if !exists {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInvalidMode.Error(), p.Mode))
}
if p.Score < 0 || p.Score > 10 {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errInvalidScore.Error(), p.Score))
}
_, exists = checksFound[n]
if exists {
return &retPolicy, sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("%v: %v", errRepeatingCheck.Error(), n))
}
checksFound[n] = true
// Add an entry to the policy.
retPolicy.Policies[n] = &CheckPolicy{
Score: int32(p.Score),
Mode: modeToProto(p.Mode),
}
}
return &retPolicy, nil
}
// GetEnabled returns the list of enabled checks.
func GetEnabled(
sp *ScorecardPolicy,
argsChecks []string,
requiredRequestTypes []checker.RequestType,
) (checker.CheckNameToFnMap, error) {
enabledChecks := checker.CheckNameToFnMap{}
switch {
case len(argsChecks) != 0:
// Populate checks to run with the `--repo` CLI argument.
for _, checkName := range argsChecks {
if !isSupportedCheck(checkName, requiredRequestTypes) {
return enabledChecks,
sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("Unsupported RequestType %s by check: %s",
fmt.Sprint(requiredRequestTypes), checkName))
}
if !enableCheck(checkName, &enabledChecks) {
return enabledChecks,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("invalid check: %s", checkName))
}
}
case sp != nil:
// Populate checks to run with policy file.
for checkName := range sp.GetPolicies() {
if !isSupportedCheck(checkName, requiredRequestTypes) {
// We silently ignore the check, like we do
// for the default case when no argsChecks
// or policy are present.
continue
}
if !enableCheck(checkName, &enabledChecks) {
return enabledChecks,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("invalid check: %s", checkName))
}
}
default:
// Enable all checks that are supported.
for checkName := range checks.GetAll() {
if !isSupportedCheck(checkName, requiredRequestTypes) {
continue
}
if !enableCheck(checkName, &enabledChecks) {
return enabledChecks,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("invalid check: %s", checkName))
}
}
}
// If a policy was passed as argument, ensure all checks
// to run have a corresponding policy.
if sp != nil && !checksHavePolicies(sp, enabledChecks) {
return enabledChecks, sce.WithMessage(sce.ErrScorecardInternal, "checks don't have policies")
}
return enabledChecks, nil
}
func checksHavePolicies(sp *ScorecardPolicy, enabledChecks checker.CheckNameToFnMap) bool {
for checkName := range enabledChecks {
_, exists := sp.GetPolicies()[checkName]
if !exists {
log.Printf("check %s has no policy declared", checkName)
return false
}
}
return true
}
func isSupportedCheck(checkName string, requiredRequestTypes []checker.RequestType) bool {
unsupported := checker.ListUnsupported(
requiredRequestTypes,
checks.GetAllWithExperimental()[checkName].SupportedRequestTypes)
return len(unsupported) == 0
}
// Enables checks by name.
func enableCheck(checkName string, enabledChecks *checker.CheckNameToFnMap) bool {
if enabledChecks != nil {
for key, checkFn := range checks.GetAllWithExperimental() {
if strings.EqualFold(key, checkName) {
(*enabledChecks)[key] = checkFn
return true
}
}
}
return false
}
// Copyright 2021 OpenSSF 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.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.27.1
// protoc v3.12.4
// source: policy.proto
package policy
import (
reflect "reflect"
sync "sync"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Mode definition.
type CheckPolicy_Mode int32
const (
CheckPolicy_DISABLED CheckPolicy_Mode = 0
CheckPolicy_ENFORCED CheckPolicy_Mode = 1
)
// Enum value maps for CheckPolicy_Mode.
var (
CheckPolicy_Mode_name = map[int32]string{
0: "DISABLED",
1: "ENFORCED",
}
CheckPolicy_Mode_value = map[string]int32{
"DISABLED": 0,
"ENFORCED": 1,
}
)
func (x CheckPolicy_Mode) Enum() *CheckPolicy_Mode {
p := new(CheckPolicy_Mode)
*p = x
return p
}
func (x CheckPolicy_Mode) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (CheckPolicy_Mode) Descriptor() protoreflect.EnumDescriptor {
return file_policy_proto_enumTypes[0].Descriptor()
}
func (CheckPolicy_Mode) Type() protoreflect.EnumType {
return &file_policy_proto_enumTypes[0]
}
func (x CheckPolicy_Mode) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use CheckPolicy_Mode.Descriptor instead.
func (CheckPolicy_Mode) EnumDescriptor() ([]byte, []int) {
return file_policy_proto_rawDescGZIP(), []int{0, 0}
}
type CheckPolicy struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Mode CheckPolicy_Mode `protobuf:"varint,1,opt,name=mode,proto3,enum=ossf.scorecard.policy.CheckPolicy_Mode" json:"mode,omitempty"`
Score int32 `protobuf:"zigzag32,2,opt,name=score,proto3" json:"score,omitempty"` // TODO: add Risk.
}
func (x *CheckPolicy) Reset() {
*x = CheckPolicy{}
if protoimpl.UnsafeEnabled {
mi := &file_policy_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CheckPolicy) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPolicy) ProtoMessage() {}
func (x *CheckPolicy) ProtoReflect() protoreflect.Message {
mi := &file_policy_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPolicy.ProtoReflect.Descriptor instead.
func (*CheckPolicy) Descriptor() ([]byte, []int) {
return file_policy_proto_rawDescGZIP(), []int{0}
}
func (x *CheckPolicy) GetMode() CheckPolicy_Mode {
if x != nil {
return x.Mode
}
return CheckPolicy_DISABLED
}
func (x *CheckPolicy) GetScore() int32 {
if x != nil {
return x.Score
}
return 0
}
type ScorecardPolicy struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Version int32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
Policies map[string]*CheckPolicy `protobuf:"bytes,2,rep,name=policies,proto3" json:"policies,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
func (x *ScorecardPolicy) Reset() {
*x = ScorecardPolicy{}
if protoimpl.UnsafeEnabled {
mi := &file_policy_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *ScorecardPolicy) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ScorecardPolicy) ProtoMessage() {}
func (x *ScorecardPolicy) ProtoReflect() protoreflect.Message {
mi := &file_policy_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ScorecardPolicy.ProtoReflect.Descriptor instead.
func (*ScorecardPolicy) Descriptor() ([]byte, []int) {
return file_policy_proto_rawDescGZIP(), []int{1}
}
func (x *ScorecardPolicy) GetVersion() int32 {
if x != nil {
return x.Version
}
return 0
}
func (x *ScorecardPolicy) GetPolicies() map[string]*CheckPolicy {
if x != nil {
return x.Policies
}
return nil
}
var File_policy_proto protoreflect.FileDescriptor
var file_policy_proto_rawDesc = []byte{
0x0a, 0x0c, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x15,
0x6f, 0x73, 0x73, 0x66, 0x2e, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x2e, 0x70,
0x6f, 0x6c, 0x69, 0x63, 0x79, 0x22, 0x84, 0x01, 0x0a, 0x0b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50,
0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x3b, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0e, 0x32, 0x27, 0x2e, 0x6f, 0x73, 0x73, 0x66, 0x2e, 0x73, 0x63, 0x6f, 0x72, 0x65,
0x63, 0x61, 0x72, 0x64, 0x2e, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, 0x43, 0x68, 0x65, 0x63,
0x6b, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x6d, 0x6f,
0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
0x11, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x22, 0x22, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65,
0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c,
0x0a, 0x08, 0x45, 0x4e, 0x46, 0x4f, 0x52, 0x43, 0x45, 0x44, 0x10, 0x01, 0x22, 0xde, 0x01, 0x0a,
0x0f, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x50, 0x0a, 0x08, 0x70, 0x6f,
0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6f,
0x73, 0x73, 0x66, 0x2e, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x2e, 0x70, 0x6f,
0x6c, 0x69, 0x63, 0x79, 0x2e, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x50, 0x6f,
0x6c, 0x69, 0x63, 0x79, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x45, 0x6e, 0x74,
0x72, 0x79, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x1a, 0x5f, 0x0a, 0x0d,
0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
0x38, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22,
0x2e, 0x6f, 0x73, 0x73, 0x66, 0x2e, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x2e,
0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x6f, 0x6c, 0x69,
0x63, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x22, 0x5a,
0x20, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x73, 0x73, 0x66,
0x2f, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x63, 0x61, 0x72, 0x64, 0x2f, 0x70, 0x6f, 0x6c, 0x69, 0x63,
0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_policy_proto_rawDescOnce sync.Once
file_policy_proto_rawDescData = file_policy_proto_rawDesc
)
func file_policy_proto_rawDescGZIP() []byte {
file_policy_proto_rawDescOnce.Do(func() {
file_policy_proto_rawDescData = protoimpl.X.CompressGZIP(file_policy_proto_rawDescData)
})
return file_policy_proto_rawDescData
}
var file_policy_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_policy_proto_goTypes = []interface{}{
(CheckPolicy_Mode)(0), // 0: ossf.scorecard.policy.CheckPolicy.Mode
(*CheckPolicy)(nil), // 1: ossf.scorecard.policy.CheckPolicy
(*ScorecardPolicy)(nil), // 2: ossf.scorecard.policy.ScorecardPolicy
nil, // 3: ossf.scorecard.policy.ScorecardPolicy.PoliciesEntry
}
var file_policy_proto_depIdxs = []int32{
0, // 0: ossf.scorecard.policy.CheckPolicy.mode:type_name -> ossf.scorecard.policy.CheckPolicy.Mode
3, // 1: ossf.scorecard.policy.ScorecardPolicy.policies:type_name -> ossf.scorecard.policy.ScorecardPolicy.PoliciesEntry
1, // 2: ossf.scorecard.policy.ScorecardPolicy.PoliciesEntry.value:type_name -> ossf.scorecard.policy.CheckPolicy
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_policy_proto_init() }
func file_policy_proto_init() {
if File_policy_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_policy_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*CheckPolicy); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_policy_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*ScorecardPolicy); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_policy_proto_rawDesc,
NumEnums: 1,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_policy_proto_goTypes,
DependencyIndexes: file_policy_proto_depIdxs,
EnumInfos: file_policy_proto_enumTypes,
MessageInfos: file_policy_proto_msgTypes,
}.Build()
File_policy_proto = out.File
file_policy_proto_rawDesc = nil
file_policy_proto_goTypes = nil
file_policy_proto_depIdxs = nil
}
// Copyright 2022 Google LLC
//
// 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 policy
func FuzzParseFromYAML(data []byte) int {
_, _ = parseFromYAML(data)
return 1
}
// Copyright 2023 OpenSSF 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 archived
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Maintained})
}
//go:embed *.yml
var fs embed.FS
const Probe = "archived"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.MaintainedResults
if r.ArchivedStatus.Status {
return trueOutcome()
}
return falseOutcome()
}
func trueOutcome() ([]finding.Finding, string, error) {
f, err := finding.NewWith(fs, Probe,
"Repository is archived.", nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
func falseOutcome() ([]finding.Finding, string, error) {
f, err := finding.NewWith(fs, Probe,
"Repository is not archived.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package blocksDeleteOnBranches
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "blocksDeleteOnBranches"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
var text string
var outcome finding.Outcome
switch {
case branch.BranchProtectionRule.AllowDeletions == nil:
text = "could not determine whether branch is protected against deletion"
outcome = finding.OutcomeNotAvailable
case *branch.BranchProtectionRule.AllowDeletions:
text = fmt.Sprintf("'allow deletion' enabled on branch '%s'", *branch.Name)
outcome = finding.OutcomeFalse
case !*branch.BranchProtectionRule.AllowDeletions:
text = fmt.Sprintf("'allow deletion' disabled on branch '%s'", *branch.Name)
outcome = finding.OutcomeTrue
default:
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package blocksForcePushOnBranches
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "blocksForcePushOnBranches"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
var text string
var outcome finding.Outcome
switch {
case branch.BranchProtectionRule.AllowForcePushes == nil:
text = "could not determine whether for push is allowed"
outcome = finding.OutcomeNotAvailable
case *branch.BranchProtectionRule.AllowForcePushes:
text = fmt.Sprintf("'force pushes' enabled on branch '%s'", *branch.Name)
outcome = finding.OutcomeFalse
case !*branch.BranchProtectionRule.AllowForcePushes:
text = fmt.Sprintf("'force pushes' disabled on branch '%s'", *branch.Name)
outcome = finding.OutcomeTrue
default:
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package branchProtectionAppliesToAdmins
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/branchprotection"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "branchProtectionAppliesToAdmins"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
p := branch.BranchProtectionRule.EnforceAdmins
text, outcome, err := branchprotection.GetTextOutcomeFromBool(p,
"branch protection settings apply to administrators",
*branch.Name)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package branchesAreProtected
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "branchesAreProtected"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
protected := (branch.Protected != nil && *branch.Protected)
var text string
var outcome finding.Outcome
if protected {
text = fmt.Sprintf("branch '%s' is protected", *branch.Name)
outcome = finding.OutcomeTrue
} else {
text = fmt.Sprintf("branch '%s' is not protected", *branch.Name)
outcome = finding.OutcomeFalse
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package codeApproved
import (
"embed"
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.CodeReview})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "codeApproved"
NumApprovedKey = "approvedChangesets"
NumTotalKey = "totalChangesets"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
rawReviewData := &raw.CodeReviewResults
return approvedRun(rawReviewData, fs, Probe)
}
// Looks through the data and validates that each changeset has been approved at least once.
func approvedRun(reviewData *checker.CodeReviewData, fs embed.FS, probeID string) ([]finding.Finding, string, error) {
changesets := reviewData.DefaultBranchChangesets
var findings []finding.Finding
if len(changesets) == 0 {
f, err := finding.NewWith(fs, Probe, "no changesets detected", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
foundHumanActivity := false
nChangesets := len(changesets)
nChanges := 0
nApproved := 0
for x := range changesets {
data := &changesets[x]
approvedChangeset, err := approved(data)
if err != nil {
f, err := finding.NewWith(fs, probeID, err.Error(), nil, finding.OutcomeError)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, probeID, nil
}
// skip bot authored changesets, which can skew single maintainer projects which otherwise dont code review
// https://github.com/ossf/scorecard/issues/2450
if approvedChangeset && data.Author.IsBot {
continue
}
nChanges += 1
if !data.Author.IsBot {
foundHumanActivity = true
}
if approvedChangeset {
nApproved += 1
}
}
var outcome finding.Outcome
var reason string
switch {
case nApproved != nChanges:
outcome = finding.OutcomeFalse
reason = fmt.Sprintf("Found %d/%d approved changesets", nApproved, nChanges)
case !foundHumanActivity:
outcome = finding.OutcomeNotApplicable
reason = fmt.Sprintf("Found no human activity in the last %d changesets", nChangesets)
default:
outcome = finding.OutcomeTrue
reason = "All changesets approved"
}
f, err := finding.NewWith(fs, probeID, reason, nil, outcome)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
f.WithValue(NumApprovedKey, strconv.Itoa(nApproved))
f.WithValue(NumTotalKey, strconv.Itoa(nChanges))
findings = append(findings, *f)
return findings, probeID, nil
}
func approved(c *checker.Changeset) (bool, error) {
switch c.ReviewPlatform {
// reviewed outside GitHub / GitLab
case checker.ReviewPlatformProw,
checker.ReviewPlatformGerrit,
checker.ReviewPlatformPhabricator,
checker.ReviewPlatformPiper:
return true, nil
}
for _, review := range c.Reviews {
if review.State == "APPROVED" && review.Author.Login != c.Author.Login {
return true, nil
}
}
return false, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package codeReviewOneReviewers
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/utils"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.CodeReview})
}
var (
//go:embed *.yml
fs embed.FS
ErrReviewerLogin = fmt.Errorf("could not find the login of a reviewer")
)
const (
Probe = "codeReviewOneReviewers"
minimumReviewers = 1
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
rawReviewData := &raw.CodeReviewResults
return codeReviewRun(rawReviewData, fs, Probe, finding.OutcomeTrue, finding.OutcomeFalse)
}
// Looks through the data and validates author and reviewers of a changeset
// Scorecard currently only supports GitHub revisions and generates a true
// score in the case of other platforms. This probe is created to ensure that
// there are a number of unique reviewers for each changeset.
func codeReviewRun(reviewData *checker.CodeReviewData, fs embed.FS, probeID string,
trueOutcome, falseOutcome finding.Outcome,
) ([]finding.Finding, string, error) {
changesets := reviewData.DefaultBranchChangesets
var findings []finding.Finding
foundHumanActivity := false
leastFoundReviewers := 0
nChangesets := len(changesets)
if nChangesets == 0 {
return nil, probeID, utils.ErrNoChangesets
}
// Loops through all changesets, if an author login cannot be retrieved: returns OutcomeNotAvailabe.
// leastFoundReviewers will be the lowest number of unique reviewers found among the changesets.
for i := range changesets {
data := &changesets[i]
if data.Author.Login == "" {
f, err := finding.NewNotAvailable(fs, probeID, "Could not retrieve the author of a changeset.", nil)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, probeID, nil
}
if !data.Author.IsBot {
foundHumanActivity = true
}
nReviewers, err := uniqueReviewers(data.Author.Login, data.Reviews)
if err != nil {
f, err := finding.NewNotAvailable(fs, probeID, "Could not retrieve the reviewer of a changeset.", nil)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, probeID, nil
} else if i == 0 || nReviewers < leastFoundReviewers {
leastFoundReviewers = nReviewers
}
}
switch {
case !foundHumanActivity:
// returns a NotAvailable outcome if all changesets were authored by bots
f, err := finding.NewNotAvailable(fs, probeID, "All changesets authored by bot(s).", nil)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, probeID, nil
case leastFoundReviewers < minimumReviewers:
// returns FalseOutcome if even a single changeset was reviewed by fewer than minimumReviewers (1).
f, err := finding.NewWith(fs, probeID, fmt.Sprintf("some changesets had <%d reviewers",
minimumReviewers), nil, falseOutcome)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
default:
// returns TrueOutcome if the lowest number of unique reviewers is at least as high as minimumReviewers (1).
f, err := finding.NewWith(fs, probeID, fmt.Sprintf(">%d reviewers found for all changesets",
minimumReviewers), nil, trueOutcome)
if err != nil {
return nil, probeID, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, probeID, nil
}
// Loops through the reviews of a changeset, returning the number or unique user logins are present.
// Reviews performed by the author don't count, and an error is returned if a reviewer login can't be retrieved.
func uniqueReviewers(changesetAuthor string, reviews []clients.Review) (int, error) {
reviewersList := make(map[string]bool)
for i := range reviews {
if reviews[i].Author.Login == "" {
return 0, ErrReviewerLogin
}
if !reviewersList[reviews[i].Author.Login] && reviews[i].Author.Login != changesetAuthor {
reviewersList[reviews[i].Author.Login] = true
}
}
return len(reviewersList), nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package contributorsFromOrgOrCompany
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
const (
minContributionsPerUser = 5
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Contributors})
}
//go:embed *.yml
var fs embed.FS
const Probe = "contributorsFromOrgOrCompany"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
users := raw.ContributorsResults.Users
if len(users) == 0 {
f, err := finding.NewWith(fs, Probe,
"Project does not have contributors.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
entities := make(map[string]bool)
for _, user := range users {
if user.NumContributions < minContributionsPerUser {
continue
}
for _, org := range user.Organizations {
entities[org.Login] = true
}
for _, comp := range user.Companies {
entities[comp] = true
}
}
if len(entities) == 0 {
f, err := finding.NewWith(fs, Probe,
"No companies/organizations have contributed to the project.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
// Convert entities map to findings slice
for e := range entities {
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("%s contributor org/company found", e), nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package createdRecently
import (
"embed"
"fmt"
"strconv"
"time"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Maintained})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "createdRecently"
LookbackDayKey = "lookBackDays"
lookBackDays = 90
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.MaintainedResults
recencyThreshold := time.Now().AddDate(0 /*years*/, 0 /*months*/, -1*lookBackDays /*days*/)
var text string
var outcome finding.Outcome
if r.CreatedAt.After(recencyThreshold) {
text = fmt.Sprintf("Repository was created within the last %d days.", lookBackDays)
outcome = finding.OutcomeTrue
} else {
text = fmt.Sprintf("Repository was not created within the last %d days.", lookBackDays)
outcome = finding.OutcomeFalse
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(LookbackDayKey, strconv.Itoa(lookBackDays))
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package dependencyUpdateToolConfigured
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.DependencyUpdateTool})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "dependencyUpdateToolConfigured"
ToolKey = "tool"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, Probe, fmt.Errorf("%w: raw", uerror.ErrNil)
}
tools := raw.DependencyUpdateToolResults.Tools
if len(tools) == 0 {
f, err := finding.NewFalse(fs, Probe, "no dependency update tool configurations found", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
var findings []finding.Finding
for i := range tools {
tool := &tools[i]
var loc *finding.Location
if len(tool.Files) > 0 {
loc = tool.Files[0].Location()
}
f, err := finding.NewTrue(fs, Probe, "detected update tool: "+tool.Name, loc)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ToolKey, tool.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package dismissesStaleReviews
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/branchprotection"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "dismissesStaleReviews"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
p := branch.BranchProtectionRule.PullRequestRule.DismissStaleReviews
text, outcome, err := branchprotection.GetTextOutcomeFromBool(p,
"stale review dismissal",
*branch.Name)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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 probes
import (
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/archived"
"github.com/ossf/scorecard/v5/probes/blocksDeleteOnBranches"
"github.com/ossf/scorecard/v5/probes/blocksForcePushOnBranches"
"github.com/ossf/scorecard/v5/probes/branchProtectionAppliesToAdmins"
"github.com/ossf/scorecard/v5/probes/branchesAreProtected"
"github.com/ossf/scorecard/v5/probes/codeApproved"
"github.com/ossf/scorecard/v5/probes/codeReviewOneReviewers"
"github.com/ossf/scorecard/v5/probes/contributorsFromOrgOrCompany"
"github.com/ossf/scorecard/v5/probes/createdRecently"
"github.com/ossf/scorecard/v5/probes/dependencyUpdateToolConfigured"
"github.com/ossf/scorecard/v5/probes/dismissesStaleReviews"
"github.com/ossf/scorecard/v5/probes/fuzzed"
"github.com/ossf/scorecard/v5/probes/hasBinaryArtifacts"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowScriptInjection"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowUntrustedCheckout"
"github.com/ossf/scorecard/v5/probes/hasFSFOrOSIApprovedLicense"
"github.com/ossf/scorecard/v5/probes/hasLicenseFile"
"github.com/ossf/scorecard/v5/probes/hasNoGitHubWorkflowPermissionUnknown"
"github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities"
"github.com/ossf/scorecard/v5/probes/hasOpenSSFBadge"
"github.com/ossf/scorecard/v5/probes/hasPermissiveLicense"
"github.com/ossf/scorecard/v5/probes/hasRecentCommits"
"github.com/ossf/scorecard/v5/probes/hasReleaseSBOM"
"github.com/ossf/scorecard/v5/probes/hasSBOM"
"github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts"
"github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember"
"github.com/ossf/scorecard/v5/probes/jobLevelPermissions"
"github.com/ossf/scorecard/v5/probes/packagedWithAutomatedWorkflow"
"github.com/ossf/scorecard/v5/probes/pinsDependencies"
"github.com/ossf/scorecard/v5/probes/releasesAreSigned"
"github.com/ossf/scorecard/v5/probes/releasesHaveProvenance"
"github.com/ossf/scorecard/v5/probes/releasesHaveVerifiedProvenance"
"github.com/ossf/scorecard/v5/probes/requiresApproversForPullRequests"
"github.com/ossf/scorecard/v5/probes/requiresCodeOwnersReview"
"github.com/ossf/scorecard/v5/probes/requiresLastPushApproval"
"github.com/ossf/scorecard/v5/probes/requiresPRsToChangeCode"
"github.com/ossf/scorecard/v5/probes/requiresUpToDateBranches"
"github.com/ossf/scorecard/v5/probes/runsStatusChecksBeforeMerging"
"github.com/ossf/scorecard/v5/probes/sastToolConfigured"
"github.com/ossf/scorecard/v5/probes/sastToolRunsOnAllCommits"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsLinks"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsText"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsVulnerabilityDisclosure"
"github.com/ossf/scorecard/v5/probes/securityPolicyPresent"
"github.com/ossf/scorecard/v5/probes/testsRunInCI"
"github.com/ossf/scorecard/v5/probes/topLevelPermissions"
"github.com/ossf/scorecard/v5/probes/unsafeblock"
"github.com/ossf/scorecard/v5/probes/webhooksUseSecrets"
)
// ProbeImpl is the implementation of a probe.
type ProbeImpl func(*checker.RawResults) ([]finding.Finding, string, error)
// IndependentProbeImpl is the implementation of an independent probe.
type IndependentProbeImpl func(*checker.CheckRequest) ([]finding.Finding, string, error)
var (
// All represents all the probes.
All []ProbeImpl
// SecurityPolicy is all the probes for the
// SecurityPolicy check.
SecurityPolicy = []ProbeImpl{
securityPolicyPresent.Run,
securityPolicyContainsLinks.Run,
securityPolicyContainsVulnerabilityDisclosure.Run,
securityPolicyContainsText.Run,
}
// DependencyToolUpdates is all the probes for the
// DependencyUpdateTool check.
DependencyToolUpdates = []ProbeImpl{
dependencyUpdateToolConfigured.Run,
}
Fuzzing = []ProbeImpl{
fuzzed.Run,
}
Packaging = []ProbeImpl{
packagedWithAutomatedWorkflow.Run,
}
License = []ProbeImpl{
hasLicenseFile.Run,
hasFSFOrOSIApprovedLicense.Run,
}
Contributors = []ProbeImpl{
contributorsFromOrgOrCompany.Run,
}
Vulnerabilities = []ProbeImpl{
hasOSVVulnerabilities.Run,
}
CodeReview = []ProbeImpl{
codeApproved.Run,
}
SAST = []ProbeImpl{
sastToolConfigured.Run,
sastToolRunsOnAllCommits.Run,
}
DangerousWorkflows = []ProbeImpl{
hasDangerousWorkflowScriptInjection.Run,
hasDangerousWorkflowUntrustedCheckout.Run,
}
Maintained = []ProbeImpl{
archived.Run,
hasRecentCommits.Run,
issueActivityByProjectMember.Run,
createdRecently.Run,
}
CIIBestPractices = []ProbeImpl{
hasOpenSSFBadge.Run,
}
BinaryArtifacts = []ProbeImpl{
hasUnverifiedBinaryArtifacts.Run,
}
Webhook = []ProbeImpl{
webhooksUseSecrets.Run,
}
CITests = []ProbeImpl{
testsRunInCI.Run,
}
SBOM = []ProbeImpl{
hasSBOM.Run,
hasReleaseSBOM.Run,
}
SignedReleases = []ProbeImpl{
releasesAreSigned.Run,
releasesHaveProvenance.Run,
}
BranchProtection = []ProbeImpl{
blocksDeleteOnBranches.Run,
blocksForcePushOnBranches.Run,
branchesAreProtected.Run,
branchProtectionAppliesToAdmins.Run,
dismissesStaleReviews.Run,
requiresApproversForPullRequests.Run,
requiresCodeOwnersReview.Run,
requiresLastPushApproval.Run,
requiresUpToDateBranches.Run,
runsStatusChecksBeforeMerging.Run,
requiresPRsToChangeCode.Run,
}
PinnedDependencies = []ProbeImpl{
pinsDependencies.Run,
}
TokenPermissions = []ProbeImpl{
hasNoGitHubWorkflowPermissionUnknown.Run,
jobLevelPermissions.Run,
topLevelPermissions.Run,
}
// Probes which aren't included by any checks.
// These still need to be listed so they can be called with --probes.
Uncategorized = []ProbeImpl{
hasPermissiveLicense.Run,
codeReviewOneReviewers.Run,
hasBinaryArtifacts.Run,
releasesHaveVerifiedProvenance.Run,
}
// Probes which don't use pre-computed raw data but rather collect it themselves.
Independent = []IndependentProbeImpl{
unsafeblock.Run,
}
)
//nolint:gochecknoinits
func init() {
All = concatMultipleProbes([][]ProbeImpl{
BinaryArtifacts,
CIIBestPractices,
CITests,
CodeReview,
Contributors,
DangerousWorkflows,
DependencyToolUpdates,
Fuzzing,
License,
Maintained,
Packaging,
SAST,
SecurityPolicy,
SignedReleases,
Uncategorized,
Vulnerabilities,
Webhook,
})
}
func concatMultipleProbes(slices [][]ProbeImpl) []ProbeImpl {
var totalLen int
for _, s := range slices {
totalLen += len(s)
}
tmp := make([]ProbeImpl, 0, totalLen)
for _, s := range slices {
tmp = append(tmp, s...)
}
return tmp
}
// Copyright 2024 OpenSSF 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 fuzzed
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Fuzzing})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "fuzzed"
ToolKey = "tool"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, Probe, fmt.Errorf("%w: raw", uerror.ErrNil)
}
fuzzers := raw.FuzzingResults.Fuzzers
if len(fuzzers) == 0 {
f, err := finding.NewFalse(fs, Probe, "no fuzzer integrations found", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
var findings []finding.Finding
for i := range fuzzers {
fuzzer := &fuzzers[i]
// The current implementation does not provide file location
// for all fuzzers. Check this first.
if len(fuzzer.Files) == 0 {
f, err := finding.NewTrue(fs, Probe, fuzzer.Name+" integration found", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ToolKey, fuzzer.Name)
findings = append(findings, *f)
}
// Files are present. Create one results for each file location.
for _, file := range fuzzer.Files {
f, err := finding.NewTrue(fs, Probe, fuzzer.Name+" integration found", file.Location())
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ToolKey, fuzzer.Name)
findings = append(findings, *f)
}
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasBinaryArtifacts
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BinaryArtifacts})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasBinaryArtifacts"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BinaryArtifactResults
var findings []finding.Finding
// Apply the policy evaluation.
if len(r.Files) == 0 {
f, err := finding.NewWith(fs, Probe,
"Repository does not have any binary artifacts.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
for i := range r.Files {
file := &r.Files[i]
f, err := finding.NewWith(fs, Probe, "binary artifact detected",
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: file.Path,
LineStart: &file.Offset,
Type: file.Type,
})
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasDangerousWorkflowScriptInjection
import (
"embed"
"fmt"
"os"
"path"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowScriptInjection/internal/patch"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.DangerousWorkflow})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasDangerousWorkflowScriptInjection"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.DangerousWorkflowResults
if r.NumWorkflows == 0 {
f, err := finding.NewWith(fs, Probe,
"Project does not have any workflows.", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
var findings []finding.Finding
var currWorkflow string
var workflow *actionlint.Workflow
var content []byte
var errs []*actionlint.Error
localPath := raw.Metadata.Metadata["localPath"]
for _, w := range r.Workflows {
if w.Type != checker.DangerousWorkflowScriptInjection {
continue
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("script injection with untrusted input '%v'", w.File.Snippet),
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: w.File.Path,
Type: w.File.Type,
LineStart: &w.File.Offset,
Snippet: &w.File.Snippet,
})
err = parseWorkflow(localPath, &w, &currWorkflow, &content, &workflow, &errs)
if err == nil {
generatePatch(&w, content, workflow, errs, f)
}
findings = append(findings, *f)
}
if len(findings) == 0 {
return falseOutcome()
}
return findings, Probe, nil
}
func parseWorkflow(
localPath string,
e *checker.DangerousWorkflow,
currWorkflow *string,
content *[]byte,
workflow **actionlint.Workflow,
errs *[]*actionlint.Error,
) error {
var err error
wp := path.Join(localPath, e.File.Path)
if *currWorkflow != wp {
// update current open file if injection in different file
*currWorkflow = wp
*content, err = os.ReadFile(wp)
if err != nil {
return err //nolint:wrapcheck // we only care about the error's existence
}
*workflow, *errs = actionlint.Parse(*content)
if len(*errs) > 0 && *workflow == nil {
// the workflow contains unrecoverable parsing errors, skip.
return err //nolint:wrapcheck // we only care about the error's existence
}
}
return nil
}
func generatePatch(
e *checker.DangerousWorkflow,
content []byte,
workflow *actionlint.Workflow,
errs []*actionlint.Error,
f *finding.Finding,
) {
findingPatch, err := patch.GeneratePatch(e.File, content, workflow, errs)
if err != nil {
return
}
f.WithPatch(&findingPatch)
f.WithRemediationMetadata(map[string]string{
"patch": findingPatch,
})
}
func falseOutcome() ([]finding.Finding, string, error) {
f, err := finding.NewWith(fs, Probe,
"Project does not have dangerous workflow(s) with possibility of script injection.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2024 OpenSSF 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 patch
import (
"bytes"
"fmt"
"regexp"
"slices"
"strings"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/rhysd/actionlint"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
sce "github.com/ossf/scorecard/v5/errors"
)
type unsafePattern struct {
idRegex *regexp.Regexp
replaceRegex *regexp.Regexp
envvarName string
}
// Fixes the script injection identified by the finding and returns a unified diff users can apply (with `git apply` or
// `patch`) to fix the workflow themselves. Should an error occur, an empty patch is returned.
func GeneratePatch(
f checker.File,
content []byte,
workflow *actionlint.Workflow,
workflowErrs []*actionlint.Error,
) (string, error) {
patchedWorkflow, err := patchWorkflow(f, content, workflow)
if err != nil {
return "", err
}
errs := validatePatchedWorkflow(patchedWorkflow, workflowErrs)
if len(errs) > 0 {
return "", fileparser.FormatActionlintError(errs)
}
return getDiff(f.Path, content, patchedWorkflow)
}
// Returns a patched version of the workflow without the script injection finding.
func patchWorkflow(f checker.File, content []byte, workflow *actionlint.Workflow) ([]byte, error) {
unsafeVar := strings.TrimSpace(f.Snippet)
lines := bytes.Split(content, []byte("\n"))
runCmdIndex := int(f.Offset - 1)
if runCmdIndex < 0 || runCmdIndex >= len(lines) {
return []byte(""), sce.WithMessage(sce.ErrScorecardInternal, "Invalid dangerous workflow offset")
}
unsafePattern, err := getUnsafePattern(unsafeVar)
if err != nil {
return []byte(""), err
}
existingEnvvars := parseExistingEnvvars(workflow)
unsafePattern, err = useExistingEnvvars(unsafePattern, existingEnvvars, unsafeVar)
if err != nil {
return []byte(""), err
}
replaceUnsafeVarWithEnvvar(lines, unsafePattern, runCmdIndex)
lines, err = addEnvvarToGlobalEnv(lines, existingEnvvars, unsafePattern, unsafeVar)
if err != nil {
return []byte(""), sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("Unknown dangerous variable: %s", unsafeVar))
}
return bytes.Join(lines, []byte("\n")), nil
}
func getUnsafePattern(unsafeVar string) (unsafePattern, error) {
unsafePatterns := []unsafePattern{
newUnsafePattern("AUTHOR_EMAIL", `github\.event\.commits.*?\.author\.email`),
newUnsafePattern("AUTHOR_EMAIL", `github\.event\.head_commit\.author\.email`),
newUnsafePattern("AUTHOR_NAME", `github\.event\.commits.*?\.author\.name`),
newUnsafePattern("AUTHOR_NAME", `github\.event\.head_commit\.author\.name`),
newUnsafePattern("COMMENT_BODY", `github\.event\.comment\.body`),
newUnsafePattern("COMMIT_MESSAGE", `github\.event\.commits.*?\.message`),
newUnsafePattern("COMMIT_MESSAGE", `github\.event\.head_commit\.message`),
newUnsafePattern("DISCUSSION_TITLE", `github\.event\.discussion\.title`),
newUnsafePattern("DISCUSSION_BODY", `github\.event\.discussion\.body`),
newUnsafePattern("ISSUE_BODY", `github\.event\.issue\.body`),
newUnsafePattern("ISSUE_COMMENT_BODY", `github\.event\.issue_comment\.comment\.body`),
newUnsafePattern("ISSUE_TITLE", `github\.event\.issue\.title`),
newUnsafePattern("PAGE_NAME", `github\.event\.pages.*?\.page_name`),
newUnsafePattern("PR_BODY", `github\.event\.pull_request\.body`),
newUnsafePattern("PR_DEFAULT_BRANCH", `github\.event\.pull_request\.head\.repo\.default_branch`),
newUnsafePattern("PR_HEAD_LABEL", `github\.event\.pull_request\.head\.label`),
newUnsafePattern("PR_HEAD_REF", `github\.event\.pull_request\.head\.ref`),
newUnsafePattern("PR_TITLE", `github\.event\.pull_request\.title`),
newUnsafePattern("REVIEW_BODY", `github\.event\.review\.body`),
newUnsafePattern("REVIEW_COMMENT_BODY", `github\.event\.review_comment\.body`),
newUnsafePattern("HEAD_REF", `github\.head_ref`),
}
for _, p := range unsafePatterns {
if p.idRegex.MatchString(unsafeVar) {
arrayVarRegex := regexp.MustCompile(`\[(.+?)\]`)
arrayIdx := arrayVarRegex.FindStringSubmatch(unsafeVar)
if len(arrayIdx) < 2 {
// not an array variable, the default envvar name is sufficient.
return p, nil
}
// Array variable (i.e. `github.event.commits[0].message`), must avoid potential conflicts.
// Add the array index to the name as a suffix, and use the exact unsafe variable name instead of the
// default, which includes a regex that will catch all instances of the array.
envvarName := fmt.Sprintf("%s_%s", p.envvarName, arrayIdx[1])
return newUnsafePattern(envvarName, regexp.QuoteMeta(unsafeVar)), nil
}
}
return unsafePattern{}, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("Unknown dangerous variable: %s", unsafeVar))
}
func newUnsafePattern(e, p string) unsafePattern {
return unsafePattern{
envvarName: e,
// Regex to simply identify the unsafe variable that triggered the finding.
// Must use a regex and not a simple string to identify possible uses of array variables
// (i.e. `github.event.commits[0].author.email`).
idRegex: regexp.MustCompile(p),
// Regex to replace the unsafe variable in a `run` command with the envvar name.
replaceRegex: regexp.MustCompile(`{{\s*.*?` + p + `.*?\s*}}`),
}
}
// Parses the envvars from the existing global `env:` block.
// Returns a map from the GitHub variable name to the envvar name (i.e. "github.event.issue.body": "ISSUE_BODY").
func parseExistingEnvvars(workflow *actionlint.Workflow) map[string]string {
envvars := make(map[string]string)
if workflow.Env == nil {
return envvars
}
r := regexp.MustCompile(`\$\{\{\s*(github\.[^\s]*?)\s*}}`)
for _, v := range workflow.Env.Vars {
value := v.Value.Value
if strings.Contains(value, "${{") {
// extract simple variable definition (without brackets, etc)
m := r.FindStringSubmatch(value)
if len(m) == 2 {
value = m[1]
envvars[value] = v.Name.Value
} else {
envvars[v.Value.Value] = v.Name.Value
}
} else {
envvars[v.Value.Value] = v.Name.Value
}
}
return envvars
}
// Identifies whether the original workflow contains envvars which may conflict with our patch.
// Should an existing envvar already handle our dangerous variable, it will be used in the patch instead of creating a
// new envvar with the same value.
// Should an existing envvar have the same name as the one that would ordinarily be used by the patch, the patch appends
// a suffix to the patch's envvar name to avoid conflicts.
//
// Returns the unsafePattern, possibly updated to consider the existing envvars.
func useExistingEnvvars(
pattern unsafePattern,
existingEnvvars map[string]string,
unsafeVar string,
) (unsafePattern, error) {
if envvar, ok := existingEnvvars[unsafeVar]; ok {
// There already exists an envvar handling our unsafe variable.
// Use that envvar instead of creating another one with the same value.
pattern.envvarName = envvar
return pattern, nil
}
// If there's an envvar with the same name as what we'd use, add a hard-coded suffix to our name to avoid conflicts.
// Clumsy but works in almost all cases, and should be rare.
for _, e := range existingEnvvars {
if e == pattern.envvarName {
pattern.envvarName += "_1"
return pattern, nil
}
}
return pattern, nil
}
// Replaces all instances of the given script injection variable with the safe environment variable.
func replaceUnsafeVarWithEnvvar(lines [][]byte, pattern unsafePattern, runIndex int) {
runIndent := getIndent(lines[runIndex])
for i, line := range lines[runIndex:] {
currLine := runIndex + i
if i > 0 && isParentLevelIndent(lines[currLine], runIndent) {
// anything at the same indent as the first line of the `- run:` block will mean the end of the run block.
break
}
lines[currLine] = pattern.replaceRegex.ReplaceAll(line, []byte(pattern.envvarName))
}
}
// Adds the necessary environment variable to the global `env:` block.
// If the `env:` block does not exist, it is created right above the `jobs:` block.
//
// Returns the new array of lines describing the workflow after inserting the new envvar.
func addEnvvarToGlobalEnv(
lines [][]byte,
existingEnvvars map[string]string,
pattern unsafePattern, unsafeVar string,
) ([][]byte, error) {
globalIndentation, err := findGlobalIndentation(lines)
if err != nil {
return lines, err
}
if _, ok := existingEnvvars[unsafeVar]; ok {
// an existing envvar already handles this unsafe var, we can simply use it
return lines, nil
}
var insertPos, envvarIndent int
if len(existingEnvvars) > 0 {
insertPos, envvarIndent = findExistingEnv(lines, globalIndentation)
} else {
lines, insertPos, err = addNewGlobalEnv(lines, globalIndentation)
if err != nil {
return lines, err
}
// position now points to `env:`, insert variables below it
insertPos++
envvarIndent = globalIndentation + getDefaultIndentStep(lines)
}
envvarDefinition := fmt.Sprintf("%s: ${{ %s }}", pattern.envvarName, unsafeVar)
lines = slices.Insert(lines, insertPos, append(bytes.Repeat([]byte(" "), envvarIndent), []byte(envvarDefinition)...))
return lines, nil
}
// Detects where the existing global `env:` block is located.
//
// Returns:
// - int: the index for the line where a new global envvar should be added (after the last existing envvar)
// - int: the indentation used for the declared environment variables
//
// Both values return -1 if the `env` block doesn't exist or is invalid.
func findExistingEnv(lines [][]byte, globalIndent int) (int, int) {
var currPos int
var line []byte
envRegex := labelRegex("env", globalIndent)
for currPos, line = range lines {
if envRegex.Match(line) {
break
}
}
if currPos >= len(lines)-1 {
// Invalid env, there must be at least one more line for an existing envvar. Shouldn't happen.
return -1, -1
}
currPos++ // move to line after `env:`
insertPos := currPos // marks the position where new envvars will be added
var envvarIndent int
for i, line := range lines[currPos:] {
if isBlankOrComment(line) {
continue
}
if isParentLevelIndent(line, globalIndent) {
// no longer declaring envvars
break
}
envvarIndent = getIndent(line)
insertPos = currPos + i + 1
}
return insertPos, envvarIndent
}
// Adds a new global environment followed by a blank line to a workflow.
// Assumes a global environment does not yet exist.
//
// Returns:
// - []string: the new array of lines describing the workflow, now with the global `env:` inserted.
// - int: the row where the `env:` block was added
func addNewGlobalEnv(lines [][]byte, globalIndentation int) ([][]byte, int, error) {
envPos, err := findNewEnvPos(lines, globalIndentation)
if err != nil {
return nil, -1, err
}
label := append(bytes.Repeat([]byte(" "), globalIndentation), []byte("env:")...)
content := [][]byte{label}
numBlankLines := getDefaultBlockSpacing(lines, globalIndentation)
for i := 0; i < numBlankLines; i++ {
content = append(content, []byte(""))
}
lines = slices.Insert(lines, envPos, content...)
return lines, envPos, nil
}
// Returns the line where a new `env:` block should be inserted: right above the `jobs:` label.
func findNewEnvPos(lines [][]byte, globalIndent int) (int, error) {
jobsRegex := labelRegex("jobs", globalIndent)
for i, line := range lines {
if jobsRegex.Match(line) {
return i, nil
}
}
return -1, sce.WithMessage(sce.ErrScorecardInternal, "Could not determine location for new environment")
}
// Returns the "global" indentation, as defined by the indentation on the required `on:` block.
// Will equal 0 in almost all cases.
func findGlobalIndentation(lines [][]byte) (int, error) {
r := regexp.MustCompile(`^\s*on:`)
for _, line := range lines {
if r.Match(line) {
return getIndent(line), nil
}
}
return -1, sce.WithMessage(sce.ErrScorecardInternal, "Could not determine global indentation")
}
// Returns the indentation of the given line. The indentation is all leading whitespace and dashes.
func getIndent(line []byte) int {
return len(line) - len(bytes.TrimLeft(line, " -"))
}
// Returns the "default" number of blank lines between blocks.
// The default is taken as the number of blank lines between the `jobs` label and the end of the preceding block.
func getDefaultBlockSpacing(lines [][]byte, globalIndent int) int {
jobsRegex := labelRegex("jobs", globalIndent)
var jobsIdx int
var line []byte
for jobsIdx, line = range lines {
if jobsRegex.Match(line) {
break
}
}
numBlanks := 0
for i := jobsIdx - 1; i >= 0; i-- {
line := lines[i]
if isBlank(line) {
numBlanks++
} else if !isComment(line) {
// If the line is neither blank nor a comment, then we've reached the end of the previous block.
break
}
}
return numBlanks
}
// Returns whether the given line is a blank line (empty or only whitespace).
func isBlank(line []byte) bool {
blank := regexp.MustCompile(`^\s*$`)
return blank.Match(line)
}
// Returns whether the given line only contains comments.
func isComment(line []byte) bool {
comment := regexp.MustCompile(`^\s*#`)
return comment.Match(line)
}
func isBlankOrComment(line []byte) bool {
return isBlank(line) || isComment(line)
}
// Returns whether the given line is at the same indentation level as the parent scope.
// For example, when walking through the document, parsing `job_foo`:
//
// job_foo:
// runs-on: ubuntu-latest # looping over these lines, we have
// uses: ./actions/foo # parent_indent = 2 (job_foo's indentation)
// ... # we know these lines belong to job_foo because
// ... # they all have indent = 4
// job_bar: # this line has job_foo's indentation, so we know job_foo is done
//
// Blank lines and those containing only comments are ignored and always return false.
func isParentLevelIndent(line []byte, parentIndent int) bool {
if isBlankOrComment(line) {
return false
}
return getIndent(line) <= parentIndent
}
func labelRegex(label string, indent int) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf("^%s%s:", strings.Repeat(" ", indent), label))
}
// Returns the default indentation step adopted in the document.
// This is taken from the difference in indentation between the `jobs:` label and the first job's label.
func getDefaultIndentStep(lines [][]byte) int {
jobs := regexp.MustCompile(`^\s*jobs:`)
var jobsIndex, jobsIndent int
for i, line := range lines {
if jobs.Match(line) {
jobsIndex = i
jobsIndent = getIndent(line)
break
}
}
jobIndent := jobsIndent + 2 // default value, should never be used
for _, line := range lines[jobsIndex+1:] {
if isBlankOrComment(line) {
continue
}
jobIndent = getIndent(line)
break
}
return jobIndent - jobsIndent
}
// Validates that the patch does not add any new syntax errors to the workflow. If the original workflow contains
// errors, then the patched version also might. As long as all the patch's errors match the original's, it is validated.
//
// Returns the array of new parsing errors caused by the patch.
func validatePatchedWorkflow(content []byte, originalErrs []*actionlint.Error) []*actionlint.Error {
_, patchedErrs := actionlint.Parse(content)
if len(patchedErrs) == 0 {
return []*actionlint.Error{}
}
if len(originalErrs) == 0 {
return patchedErrs
}
normalizeMsg := func(msg string) string {
// one of the error messages contains line metadata that may legitimately change after a patch.
// Only looking at the errors' first sentence eliminates this.
return strings.Split(msg, ".")[0]
}
var newErrs []*actionlint.Error
o := 0
orig := originalErrs[o]
origMsg := normalizeMsg(orig.Message)
for _, patched := range patchedErrs {
if o == len(originalErrs) {
// no more errors in the original workflow, must be an error from our patch
newErrs = append(newErrs, patched)
continue
}
msg := normalizeMsg(patched.Message)
if orig.Column == patched.Column && orig.Kind == patched.Kind && origMsg == msg {
// Matched error, therefore not due to our patch.
o++
if o < len(originalErrs) {
orig = originalErrs[o]
origMsg = normalizeMsg(orig.Message)
}
} else {
newErrs = append(newErrs, patched)
}
}
return newErrs
}
// Returns the changes between the original and patched workflows as a unified diff (same as `git diff` or `diff -u`).
func getDiff(path string, original, patched []byte) (string, error) {
// initialize an in-memory repository
repo, err := newInMemoryRepo()
if err != nil {
return "", err
}
// commit original workflow
originalCommit, err := commitWorkflow(path, original, repo)
if err != nil {
return "", err
}
// commit patched workflow
patchedCommit, err := commitWorkflow(path, patched, repo)
if err != nil {
return "", err
}
// get diff between those commits
return toUnifiedDiff(originalCommit, patchedCommit)
}
func newInMemoryRepo() (*git.Repository, error) {
repo, err := git.Init(memory.NewStorage(), memfs.New())
if err != nil {
return nil, fmt.Errorf("git.Init: %w", err)
}
return repo, nil
}
// Commits the workflow at the given path to the in-memory repository.
func commitWorkflow(path string, contents []byte, repo *git.Repository) (*object.Commit, error) {
worktree, err := repo.Worktree()
if err != nil {
return nil, fmt.Errorf("repo.Worktree: %w", err)
}
filesystem := worktree.Filesystem
// create (or overwrite) file
df, err := filesystem.Create(path)
if err != nil {
return nil, fmt.Errorf("filesystem.Create: %w", err)
}
_, err = df.Write(contents)
if err != nil {
return nil, fmt.Errorf("df.Write: %w", err)
}
df.Close()
// commit file to in-memory repository
_, err = worktree.Add(path)
if err != nil {
return nil, fmt.Errorf("worktree.Add: %w", err)
}
hash, err := worktree.Commit("x", &git.CommitOptions{})
if err != nil {
return nil, fmt.Errorf("worktree.Commit: %w", err)
}
commit, err := repo.CommitObject(hash)
if err != nil {
return nil, fmt.Errorf("repo.CommitObject: %w", err)
}
return commit, nil
}
// Returns a unified diff describing the difference between the given commits.
func toUnifiedDiff(originalCommit, patchedCommit *object.Commit) (string, error) {
patch, err := originalCommit.Patch(patchedCommit)
if err != nil {
return "", fmt.Errorf("originalCommit.Patch: %w", err)
}
builder := strings.Builder{}
err = patch.Encode(&builder)
if err != nil {
return "", fmt.Errorf("patch.Encode: %w", err)
}
return builder.String(), nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasDangerousWorkflowUntrustedCheckout
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.DangerousWorkflow})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasDangerousWorkflowUntrustedCheckout"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.DangerousWorkflowResults
if r.NumWorkflows == 0 {
f, err := finding.NewWith(fs, Probe,
"Project does not have any workflows.", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
var findings []finding.Finding
for _, e := range r.Workflows {
if e.Type == checker.DangerousWorkflowUntrustedCheckout {
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("untrusted code checkout '%v'", e.File.Snippet),
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: e.File.Path,
Type: e.File.Type,
LineStart: &e.File.Offset,
Snippet: &e.File.Snippet,
})
findings = append(findings, *f)
}
}
if len(findings) == 0 {
return falseOutcome()
}
return findings, Probe, nil
}
func falseOutcome() ([]finding.Finding, string, error) {
f, err := finding.NewWith(fs, Probe,
"Project does not have workflow(s) with untrusted checkout.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasFSFOrOSIApprovedLicense
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.License})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasFSFOrOSIApprovedLicense"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
if len(raw.LicenseResults.LicenseFiles) == 0 {
f, err := finding.NewWith(fs, Probe,
"project does not have a license file", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
for _, licenseFile := range raw.LicenseResults.LicenseFiles {
if !licenseFile.LicenseInformation.Approved {
continue
}
loc := licenseFile.File.Location()
f, err := finding.NewTrue(fs, Probe, "FSF or OSI recognized license: "+licenseFile.LicenseInformation.Name, loc)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
f, err := finding.NewWith(fs, Probe,
"project license file does not contain an FSF or OSI license.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasLicenseFile
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.License})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasLicenseFile"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
licenseFiles := raw.LicenseResults.LicenseFiles
if len(licenseFiles) == 0 {
f, err := finding.NewFalse(fs, Probe, "project does not have a license file", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
} else {
for _, licenseFile := range licenseFiles {
loc := licenseFile.File.Location()
f, err := finding.NewTrue(fs, Probe, "project has a license file", loc)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
return findings, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package hasNoGitHubWorkflowPermissionUnknown
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/internal/utils/permissions"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const Probe = "hasNoGitHubWorkflowPermissionUnknown"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
results := raw.TokenPermissionsResults
var findings []finding.Finding
if results.NumTokens == 0 {
f, err := finding.NewWith(fs, Probe,
"No token permissions found",
nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for _, r := range results.TokenPermissions {
if r.Type != checker.PermissionLevelUnknown {
continue
}
// Create finding
f, err := permissions.CreateFalseFinding(r, Probe, fs, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"no workflows with unknown permissions",
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasOSVVulnerabilities
import (
"embed"
"errors"
"fmt"
"strings"
//nolint:staticcheck // Waiting on V2 https://github.com/ossf/scorecard/issues/4431
"github.com/google/osv-scanner/pkg/grouper"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Vulnerabilities})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasOSVVulnerabilities"
var errNoVulnID = errors.New("no vuln ID")
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
// if no vulns were found
if len(raw.VulnerabilitiesResults.Vulnerabilities) == 0 {
f, err := finding.NewWith(fs, Probe,
"Project does not contain OSV vulnerabilities", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
//nolint:staticcheck // Waiting on V2 https://github.com/ossf/scorecard/issues/4431
aliasVulnerabilities := []grouper.IDAliases{}
for _, vuln := range raw.VulnerabilitiesResults.Vulnerabilities {
//nolint:staticcheck // Waiting on V2 https://github.com/ossf/scorecard/issues/4431
aliasVulnerabilities = append(aliasVulnerabilities, grouper.IDAliases(vuln))
}
//nolint:staticcheck // Waiting on V2 https://github.com/ossf/scorecard/issues/4431
IDs := grouper.Group(aliasVulnerabilities)
for _, vuln := range IDs {
if len(vuln.IDs) == 0 {
return nil, Probe, errNoVulnID
}
f, err := finding.NewWith(fs, Probe,
"Project contains OSV vulnerabilities", nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithMessage("Project is vulnerable to: " + strings.Join(vuln.IDs, " / "))
f = f.WithRemediationMetadata(map[string]string{
"osvid": vuln.IDs[0],
})
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasOpenSSFBadge
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.CIIBestPractices})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "hasOpenSSFBadge"
LevelKey = "badgeLevel"
GoldLevel = "Gold"
SilverLevel = "Silver"
PassingLevel = "Passing"
InProgressLevel = "InProgress"
UnknownLevel = "Unknown"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.CIIBestPracticesResults
var badgeLevel string
switch r.Badge {
case clients.Gold:
badgeLevel = GoldLevel
case clients.Silver:
badgeLevel = SilverLevel
case clients.Passing:
badgeLevel = PassingLevel
case clients.InProgress:
badgeLevel = InProgressLevel
case clients.Unknown:
badgeLevel = UnknownLevel
default:
f, err := finding.NewWith(fs, Probe,
"Project does not have an OpenSSF badge", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("OpenSSF best practice badge found at %s level.", badgeLevel),
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(LevelKey, badgeLevel)
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package hasPermissiveLicense
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.License})
}
const Probe = "hasPermissiveLicense"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
if len(raw.LicenseResults.LicenseFiles) == 0 {
f, err := finding.NewWith(fs, Probe,
"project does not have a license file", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
for i := range raw.LicenseResults.LicenseFiles {
licenseFile := raw.LicenseResults.LicenseFiles[i]
spdxID := licenseFile.LicenseInformation.SpdxID
switch spdxID {
case
"Unlicense",
"Beerware",
"Apache-2.0",
"MIT",
"0BSD",
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD-4-Clause",
"APSL-1.0",
"APSL-1.1",
"APSL-1.2",
"APSL-2.0",
"ECL-1.0",
"ECL-2.0",
"EFL-1.0",
"EFL-2.0",
"Fair",
"FSFAP",
"WTFPL",
"Zlib",
"CNRI-Python",
"ISC",
"Intel":
// Store the license name in the msg
msg := licenseFile.LicenseInformation.Name
loc := &finding.Location{
Type: licenseFile.File.Type,
Path: licenseFile.File.Path,
LineStart: &licenseFile.File.Offset,
LineEnd: &licenseFile.File.EndOffset,
Snippet: &licenseFile.File.Snippet,
}
f, err := finding.NewWith(fs, Probe,
msg, loc,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
}
f, err := finding.NewWith(fs, Probe,
"", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasRecentCommits
import (
"embed"
"fmt"
"strconv"
"time"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Maintained})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "hasRecentCommits"
NumCommitsKey = "commitsWithinThreshold"
LookbackDayKey = "lookBackDays"
lookBackDays = 90
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
r := raw.MaintainedResults
threshold := time.Now().AddDate(0 /*years*/, 0 /*months*/, -1*lookBackDays /*days*/)
commitsWithinThreshold := 0
for i := range r.DefaultBranchCommits {
commit := r.DefaultBranchCommits[i]
if commit.CommittedDate.After(threshold) {
commitsWithinThreshold++
}
}
var text string
var outcome finding.Outcome
if commitsWithinThreshold > 0 {
text = "Found a contribution within the threshold."
outcome = finding.OutcomeTrue
} else {
text = "Did not find contribution within the threshold."
outcome = finding.OutcomeFalse
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValues(map[string]string{
NumCommitsKey: strconv.Itoa(commitsWithinThreshold),
LookbackDayKey: strconv.Itoa(lookBackDays),
})
findings = append(findings, *f)
return findings, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package hasReleaseSBOM
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SBOM})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "hasReleaseSBOM"
AssetNameKey = "assetName"
AssetURLKey = "assetURL"
missingSbom = "Project is not publishing an SBOM file as part of a release or CICD"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
var msg string
SBOMFiles := raw.SBOMResults.SBOMFiles
for i := range SBOMFiles {
SBOMFile := SBOMFiles[i]
if SBOMFile.File.Type != finding.FileTypeURL {
continue
}
loc := SBOMFile.File.Location()
msg = "Project publishes an SBOM file as part of a release or CICD"
f, err := finding.NewTrue(fs, Probe, msg, loc)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f.Values = map[string]string{
AssetNameKey: SBOMFile.Name,
AssetURLKey: SBOMFile.File.Path,
}
findings = append(findings, *f)
}
if len(findings) == 0 {
msg = missingSbom
f, err := finding.NewFalse(fs, Probe, msg, nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package hasSBOM
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SBOM})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasSBOM"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
var msg string
SBOMFiles := raw.SBOMResults.SBOMFiles
if len(SBOMFiles) == 0 {
msg = "Project does not have a SBOM file"
f, err := finding.NewFalse(fs, Probe, msg, nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range SBOMFiles {
SBOMFile := SBOMFiles[i]
loc := SBOMFile.File.Location()
msg = "Project has a SBOM file"
f, err := finding.NewTrue(fs, Probe, msg, loc)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package hasUnverifiedBinaryArtifacts
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BinaryArtifacts})
}
//go:embed *.yml
var fs embed.FS
const Probe = "hasUnverifiedBinaryArtifacts"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BinaryArtifactResults
var findings []finding.Finding
for i := range r.Files {
file := &r.Files[i]
if file.Type == finding.FileTypeBinaryVerified {
continue
}
f, err := finding.NewWith(fs, Probe, "binary artifact detected",
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: file.Path,
LineStart: &file.Offset,
Type: file.Type,
})
findings = append(findings, *f)
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"Repository does not have binary artifacts.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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 branchprotection
import (
"errors"
"fmt"
"github.com/ossf/scorecard/v5/finding"
)
var errWrongValue = errors.New("wrong value, should not happen")
func GetTextOutcomeFromBool(b *bool, rule, branchName string) (string, finding.Outcome, error) {
switch {
case b == nil:
msg := fmt.Sprintf("unable to retrieve whether '%s' is required to merge on branch '%s'", rule, branchName)
return msg, finding.OutcomeNotAvailable, nil
case *b:
msg := fmt.Sprintf("'%s' is required to merge on branch '%s'", rule, branchName)
return msg, finding.OutcomeTrue, nil
case !*b:
msg := fmt.Sprintf("'%s' is disabled on branch '%s'", rule, branchName)
return msg, finding.OutcomeFalse, nil
}
return "", finding.OutcomeError, errWrongValue
}
// Copyright 2024 OpenSSF 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 permissions
import (
"embed"
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
)
func createText(t checker.TokenPermission) (string, error) {
// By default, use the message already present.
if t.Msg != nil {
return *t.Msg, nil
}
// Ensure there's no implementation bug.
if t.LocationType == nil {
return "", sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil")
}
// Use a different text depending on the type.
if t.Type == checker.PermissionLevelUndeclared {
return fmt.Sprintf("no %s permission defined", *t.LocationType), nil
}
if t.Value == nil {
return "", sce.WithMessage(sce.ErrScorecardInternal, "Value fields is nil")
}
if t.Name == nil {
return fmt.Sprintf("%s permissions set to '%v'", *t.LocationType,
*t.Value), nil
}
return fmt.Sprintf("%s '%v' permission set to '%v'", *t.LocationType,
*t.Name, *t.Value), nil
}
func CreateFalseFinding(r checker.TokenPermission,
probe string,
fs embed.FS,
metadata map[string]string,
) (*finding.Finding, error) {
// Create finding
text, err := createText(r)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
f, err := finding.NewWith(fs, probe,
text, nil, finding.OutcomeFalse)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
if r.File != nil {
f = f.WithLocation(r.File.Location())
workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/")
f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath})
}
if metadata != nil {
f = f.WithRemediationMetadata(metadata)
}
if r.Name != nil {
f = f.WithValue("tokenName", *r.Name)
}
f = f.WithValue("permissionLevel", string(r.Type))
return f, nil
}
func ReadTrueLevelFinding(probe string,
fs embed.FS,
r checker.TokenPermission,
metadata map[string]string,
) (*finding.Finding, error) {
text, err := createText(r)
if err != nil {
return nil, err
}
f, err := finding.NewWith(fs, probe, text, nil, finding.OutcomeTrue)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
if r.File != nil {
f = f.WithLocation(r.File.Location())
workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/")
f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath})
}
if metadata != nil {
f = f.WithRemediationMetadata(metadata)
}
f = f.WithValue("permissionLevel", "read")
return f, nil
}
func CreateNoneFinding(probe string,
fs embed.FS,
r checker.TokenPermission,
metadata map[string]string,
) (*finding.Finding, error) {
// Create finding
f, err := finding.NewWith(fs, probe,
"found token with 'none' permissions",
nil, finding.OutcomeFalse)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
if r.File != nil {
f = f.WithLocation(r.File.Location())
workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/")
f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath})
}
if metadata != nil {
f = f.WithRemediationMetadata(metadata)
}
f = f.WithValue("permissionLevel", string(r.Type))
return f, nil
}
func CreateUndeclaredFinding(probe string,
fs embed.FS,
r checker.TokenPermission,
metadata map[string]string,
) (*finding.Finding, error) {
var f *finding.Finding
var err error
switch {
case r.LocationType == nil:
f, err = finding.NewWith(fs, probe,
"could not determine the location type",
nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
case *r.LocationType == checker.PermissionLocationTop,
*r.LocationType == checker.PermissionLocationJob:
// Create finding
f, err = CreateFalseFinding(r, probe, fs, metadata)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
default:
f, err = finding.NewWith(fs, probe,
"could not determine the location type",
nil, finding.OutcomeError)
if err != nil {
return nil, fmt.Errorf("create finding: %w", err)
}
}
f = f.WithValue("permissionLevel", string(r.Type))
return f, nil
}
// Copyright 2023 OpenSSF 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 secpolicy
import (
"github.com/ossf/scorecard/v5/checker"
)
func CountSecInfo(secInfo []checker.SecurityPolicyInformation,
infoType checker.SecurityPolicyInformationType,
unique bool,
) int {
keys := make(map[string]bool)
count := 0
for _, entry := range secInfo {
if _, present := keys[entry.InformationValue.Match]; !present && entry.InformationType == infoType {
keys[entry.InformationValue.Match] = true
count += 1
} else if !unique && entry.InformationType == infoType {
count += 1
}
}
return count
}
func FindSecInfo(secInfo []checker.SecurityPolicyInformation,
infoType checker.SecurityPolicyInformationType,
unique bool,
) []checker.SecurityPolicyInformation {
keys := make(map[string]bool)
var secList []checker.SecurityPolicyInformation
for _, entry := range secInfo {
if _, present := keys[entry.InformationValue.Match]; !present && entry.InformationType == infoType {
keys[entry.InformationValue.Match] = true
secList = append(secList, entry)
} else if !unique && entry.InformationType == infoType {
secList = append(secList, entry)
}
}
return secList
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package issueActivityByProjectMember
import (
"embed"
"fmt"
"strconv"
"time"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Maintained})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "issueActivityByProjectMember"
NumIssuesKey = "numberOfIssuesUpdatedWithinThreshold"
LookbackDayKey = "lookBackDays"
lookBackDays = 90
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.MaintainedResults
numberOfIssuesUpdatedWithinThreshold := 0
// Look for activity in past `lookBackDays`.
threshold := time.Now().AddDate(0 /*years*/, 0 /*months*/, -1*lookBackDays /*days*/)
var findings []finding.Finding
for i := range r.Issues {
if hasActivityByCollaboratorOrHigher(&r.Issues[i], threshold) {
numberOfIssuesUpdatedWithinThreshold++
}
}
var text string
var outcome finding.Outcome
if numberOfIssuesUpdatedWithinThreshold > 0 {
text = "Found a issue within the threshold."
outcome = finding.OutcomeTrue
} else {
text = "Did not find issues within the threshold."
outcome = finding.OutcomeFalse
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValues(map[string]string{
NumIssuesKey: strconv.Itoa(numberOfIssuesUpdatedWithinThreshold),
LookbackDayKey: strconv.Itoa(lookBackDays),
})
findings = append(findings, *f)
return findings, Probe, nil
}
// hasActivityByCollaboratorOrHigher returns true if the issue was created or commented on by an
// owner/collaborator/member since the threshold.
func hasActivityByCollaboratorOrHigher(issue *clients.Issue, threshold time.Time) bool {
if issue == nil {
return false
}
hasAuthorAssociation := issue.AuthorAssociation != nil
if hasAuthorAssociation &&
issue.AuthorAssociation.Gte(clients.RepoAssociationCollaborator) &&
issue.CreatedAt != nil && issue.CreatedAt.After(threshold) {
// The creator of the issue is a collaborator or higher.
return true
}
for _, comment := range issue.Comments {
if comment.AuthorAssociation == nil {
continue
}
if comment.AuthorAssociation.Gte(clients.RepoAssociationCollaborator) &&
comment.CreatedAt != nil &&
comment.CreatedAt.After(threshold) {
// The author of the comment is a collaborator or higher.
return true
}
}
return false
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package jobLevelPermissions
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/internal/utils/permissions"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const (
Probe = "jobLevelPermissions"
PermissionLevelKey = "permissionLevel"
TokenNameKey = "tokenName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
results := raw.TokenPermissionsResults
var findings []finding.Finding
if results.NumTokens == 0 {
f, err := finding.NewWith(fs, Probe,
"No token permissions found",
nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for _, r := range results.TokenPermissions {
if r.LocationType == nil {
continue
}
if *r.LocationType != checker.PermissionLocationJob {
continue
}
switch r.Type {
case checker.PermissionLevelNone:
f, err := permissions.CreateNoneFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
case checker.PermissionLevelUndeclared:
f, err := permissions.CreateUndeclaredFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
case checker.PermissionLevelRead:
f, err := permissions.ReadTrueLevelFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
default:
// to satisfy linter
}
if r.Name == nil {
continue
}
f, err := permissions.CreateFalseFinding(r, Probe, fs, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(PermissionLevelKey, string(r.Type))
f = f.WithValue(TokenNameKey, *r.Name)
findings = append(findings, *f)
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"no job-level permissions found",
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package packagedWithAutomatedWorkflow
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Packaging})
}
//go:embed *.yml
var fs embed.FS
const Probe = "packagedWithAutomatedWorkflow"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.PackagingResults
var findings []finding.Finding
for _, p := range r.Packages {
if p.Msg != nil {
continue
}
// Presence of a single non-debug message means the
// check passes.
f, err := finding.NewWith(fs, Probe,
"Project packages its releases by way of GitHub Actions.", nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
loc := &finding.Location{}
if p.File != nil {
loc.Path = p.File.Path
loc.Type = p.File.Type
loc.LineStart = &p.File.Offset
}
f = f.WithLocation(loc)
findings = append(findings, *f)
}
if len(findings) > 0 {
return findings, Probe, nil
}
f, err := finding.NewWith(fs, Probe,
"no GitHub/GitLab publishing workflow detected.", nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package pinsDependencies
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.PinnedDependencies})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "pinsDependencies"
DepTypeKey = "dependencyType"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
r := raw.PinningDependenciesResults
for i := range r.ProcessingErrors {
e := r.ProcessingErrors[i]
f, err := finding.NewWith(fs, Probe, generateTextIncompleteResults(e),
&e.Location, finding.OutcomeError)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
for i := range r.Dependencies {
rr := r.Dependencies[i]
loc := rr.Location.Location()
f, err := finding.NewWith(fs, Probe, "", loc, finding.OutcomeNotSupported)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
if rr.Location == nil {
if rr.Msg == nil {
e := sce.WithMessage(sce.ErrScorecardInternal, "empty File field")
return findings, Probe, e
}
f = f.WithMessage(*rr.Msg).WithOutcome(finding.OutcomeNotSupported)
findings = append(findings, *f)
continue
}
if rr.Msg != nil {
f = f.WithMessage(*rr.Msg).WithOutcome(finding.OutcomeNotSupported)
findings = append(findings, *f)
continue
}
if rr.Pinned == nil {
f = f.WithMessage(fmt.Sprintf("%s has empty Pinned field", rr.Type)).
WithOutcome(finding.OutcomeNotSupported)
findings = append(findings, *f)
continue
}
if !*rr.Pinned {
f = f.WithMessage(generateTextUnpinned(&rr)).
WithOutcome(finding.OutcomeFalse)
if rr.Remediation != nil {
f.Remediation = rr.Remediation
}
f = f.WithValues(map[string]string{
DepTypeKey: string(rr.Type),
})
findings = append(findings, *f)
} else {
f = f.WithMessage("").WithOutcome(finding.OutcomeTrue)
f = f.WithValues(map[string]string{
DepTypeKey: string(rr.Type),
})
findings = append(findings, *f)
}
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe, "no dependencies found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
return findings, Probe, nil
}
func generateTextIncompleteResults(e checker.ElementError) string {
return fmt.Sprintf("Possibly incomplete results: %s", e.Err)
}
func generateTextUnpinned(rr *checker.Dependency) string {
if rr.Type == checker.DependencyUseTypeGHAction {
// Check if we are dealing with a GitHub action or a third-party one.
gitHubOwned := fileparser.IsGitHubOwnedAction(rr.Location.Snippet)
owner := generateOwnerToDisplay(gitHubOwned)
return fmt.Sprintf("%s not pinned by hash", owner)
}
return fmt.Sprintf("%s not pinned by hash", rr.Type)
}
func generateOwnerToDisplay(gitHubOwned bool) string {
if gitHubOwned {
return fmt.Sprintf("GitHub-owned %s", checker.DependencyUseTypeGHAction)
}
return fmt.Sprintf("third-party %s", checker.DependencyUseTypeGHAction)
}
// Copyright 2025 Google LLC
//
// 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 probes
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
gfh "github.com/AdaLogics/go-fuzz-headers"
"github.com/ossf/scorecard/v5/probes/archived"
"github.com/ossf/scorecard/v5/probes/blocksDeleteOnBranches"
"github.com/ossf/scorecard/v5/probes/blocksForcePushOnBranches"
"github.com/ossf/scorecard/v5/probes/branchProtectionAppliesToAdmins"
"github.com/ossf/scorecard/v5/probes/branchesAreProtected"
"github.com/ossf/scorecard/v5/probes/codeApproved"
"github.com/ossf/scorecard/v5/probes/codeReviewOneReviewers"
"github.com/ossf/scorecard/v5/probes/contributorsFromOrgOrCompany"
"github.com/ossf/scorecard/v5/probes/createdRecently"
"github.com/ossf/scorecard/v5/probes/dependencyUpdateToolConfigured"
"github.com/ossf/scorecard/v5/probes/dismissesStaleReviews"
"github.com/ossf/scorecard/v5/probes/fuzzed"
"github.com/ossf/scorecard/v5/probes/hasBinaryArtifacts"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowScriptInjection"
"github.com/ossf/scorecard/v5/probes/hasDangerousWorkflowUntrustedCheckout"
"github.com/ossf/scorecard/v5/probes/hasFSFOrOSIApprovedLicense"
"github.com/ossf/scorecard/v5/probes/hasLicenseFile"
"github.com/ossf/scorecard/v5/probes/hasNoGitHubWorkflowPermissionUnknown"
"github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities"
"github.com/ossf/scorecard/v5/probes/hasOpenSSFBadge"
"github.com/ossf/scorecard/v5/probes/hasPermissiveLicense"
"github.com/ossf/scorecard/v5/probes/hasRecentCommits"
"github.com/ossf/scorecard/v5/probes/hasReleaseSBOM"
"github.com/ossf/scorecard/v5/probes/hasSBOM"
"github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts"
"github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember"
"github.com/ossf/scorecard/v5/probes/jobLevelPermissions"
"github.com/ossf/scorecard/v5/probes/packagedWithAutomatedWorkflow"
"github.com/ossf/scorecard/v5/probes/pinsDependencies"
"github.com/ossf/scorecard/v5/probes/releasesAreSigned"
"github.com/ossf/scorecard/v5/probes/releasesHaveProvenance"
"github.com/ossf/scorecard/v5/probes/releasesHaveVerifiedProvenance"
"github.com/ossf/scorecard/v5/probes/requiresApproversForPullRequests"
"github.com/ossf/scorecard/v5/probes/requiresCodeOwnersReview"
"github.com/ossf/scorecard/v5/probes/requiresLastPushApproval"
"github.com/ossf/scorecard/v5/probes/requiresPRsToChangeCode"
"github.com/ossf/scorecard/v5/probes/requiresUpToDateBranches"
"github.com/ossf/scorecard/v5/probes/runsStatusChecksBeforeMerging"
"github.com/ossf/scorecard/v5/probes/sastToolConfigured"
"github.com/ossf/scorecard/v5/probes/sastToolRunsOnAllCommits"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsLinks"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsText"
"github.com/ossf/scorecard/v5/probes/securityPolicyContainsVulnerabilityDisclosure"
"github.com/ossf/scorecard/v5/probes/securityPolicyPresent"
"github.com/ossf/scorecard/v5/probes/testsRunInCI"
"github.com/ossf/scorecard/v5/probes/topLevelPermissions"
"github.com/ossf/scorecard/v5/probes/unsafeblock"
"github.com/ossf/scorecard/v5/probes/webhooksUseSecrets"
)
var (
probeDefinitionPath = "/tmp/probedefinitions"
emptyName = ""
)
func writeProbeFile(probeId, yamlContents string) error {
err := os.MkdirAll(filepath.Join(probeDefinitionPath, probeId), 0750)
if err != nil {
return err
}
err = os.WriteFile(filepath.Join(probeDefinitionPath, probeId, "def.yml"),
[]byte(yamlContents),
0660)
return err
}
// Scorecard reads from the filesystem, so we write the def.yml files to disk
func init() {
err := os.MkdirAll(probeDefinitionPath, 0750)
if err != nil {
panic(err)
}
yamlContents := archived.YmlFile
if err = writeProbeFile("archived", yamlContents); err != nil {
panic(err)
}
yamlContents = blocksDeleteOnBranches.YmlFile
if err = writeProbeFile("blocksDeleteOnBranches", yamlContents); err != nil {
panic(err)
}
yamlContents = blocksForcePushOnBranches.YmlFile
if err = writeProbeFile("blocksForcePushOnBranches", yamlContents); err != nil {
panic(err)
}
yamlContents = branchProtectionAppliesToAdmins.YmlFile
if err = writeProbeFile("branchProtectionAppliesToAdmins", yamlContents); err != nil {
panic(err)
}
yamlContents = branchesAreProtected.YmlFile
if err = writeProbeFile("branchesAreProtected", yamlContents); err != nil {
panic(err)
}
yamlContents = codeApproved.YmlFile
if err = writeProbeFile("codeApproved", yamlContents); err != nil {
panic(err)
}
yamlContents = codeReviewOneReviewers.YmlFile
if err = writeProbeFile("codeReviewOneReviewers", yamlContents); err != nil {
panic(err)
}
yamlContents = contributorsFromOrgOrCompany.YmlFile
if err = writeProbeFile("contributorsFromOrgOrCompany", yamlContents); err != nil {
panic(err)
}
yamlContents = createdRecently.YmlFile
if err = writeProbeFile("createdRecently", yamlContents); err != nil {
panic(err)
}
yamlContents = dependencyUpdateToolConfigured.YmlFile
if err = writeProbeFile("dependencyUpdateToolConfigured", yamlContents); err != nil {
panic(err)
}
yamlContents = dismissesStaleReviews.YmlFile
if err = writeProbeFile("dismissesStaleReviews", yamlContents); err != nil {
panic(err)
}
yamlContents = fuzzed.YmlFile
if err = writeProbeFile("fuzzed", yamlContents); err != nil {
panic(err)
}
yamlContents = hasBinaryArtifacts.YmlFile
if err = writeProbeFile("hasBinaryArtifacts", yamlContents); err != nil {
panic(err)
}
yamlContents = hasDangerousWorkflowScriptInjection.YmlFile
if err = writeProbeFile("hasDangerousWorkflowScriptInjection", yamlContents); err != nil {
panic(err)
}
yamlContents = hasDangerousWorkflowUntrustedCheckout.YmlFile
if err = writeProbeFile("hasDangerousWorkflowUntrustedCheckout", yamlContents); err != nil {
panic(err)
}
yamlContents = hasFSFOrOSIApprovedLicense.YmlFile
if err = writeProbeFile("hasFSFOrOSIApprovedLicense", yamlContents); err != nil {
panic(err)
}
yamlContents = hasLicenseFile.YmlFile
if err = writeProbeFile("hasLicenseFile", yamlContents); err != nil {
panic(err)
}
yamlContents = hasNoGitHubWorkflowPermissionUnknown.YmlFile
if err = writeProbeFile("hasNoGitHubWorkflowPermissionUnknown", yamlContents); err != nil {
panic(err)
}
yamlContents = hasOSVVulnerabilities.YmlFile
if err = writeProbeFile("hasOSVVulnerabilities", yamlContents); err != nil {
panic(err)
}
yamlContents = hasOpenSSFBadge.YmlFile
if err = writeProbeFile("hasOpenSSFBadge", yamlContents); err != nil {
panic(err)
}
yamlContents = hasPermissiveLicense.YmlFile
if err = writeProbeFile("hasPermissiveLicense", yamlContents); err != nil {
panic(err)
}
yamlContents = hasRecentCommits.YmlFile
if err = writeProbeFile("hasRecentCommits", yamlContents); err != nil {
panic(err)
}
yamlContents = hasReleaseSBOM.YmlFile
if err = writeProbeFile("hasReleaseSBOM", yamlContents); err != nil {
panic(err)
}
yamlContents = hasSBOM.YmlFile
if err = writeProbeFile("hasSBOM", yamlContents); err != nil {
panic(err)
}
yamlContents = hasUnverifiedBinaryArtifacts.YmlFile
if err = writeProbeFile("hasUnverifiedBinaryArtifacts", yamlContents); err != nil {
panic(err)
}
yamlContents = issueActivityByProjectMember.YmlFile
if err = writeProbeFile("issueActivityByProjectMember", yamlContents); err != nil {
panic(err)
}
yamlContents = jobLevelPermissions.YmlFile
if err = writeProbeFile("jobLevelPermissions", yamlContents); err != nil {
panic(err)
}
yamlContents = packagedWithAutomatedWorkflow.YmlFile
if err = writeProbeFile("packagedWithAutomatedWorkflow", yamlContents); err != nil {
panic(err)
}
yamlContents = pinsDependencies.YmlFile
if err = writeProbeFile("pinsDependencies", yamlContents); err != nil {
panic(err)
}
yamlContents = releasesAreSigned.YmlFile
if err = writeProbeFile("releasesAreSigned", yamlContents); err != nil {
panic(err)
}
yamlContents = releasesHaveProvenance.YmlFile
if err = writeProbeFile("releasesHaveProvenance", yamlContents); err != nil {
panic(err)
}
yamlContents = releasesHaveVerifiedProvenance.YmlFile
if err = writeProbeFile("releasesHaveVerifiedProvenance", yamlContents); err != nil {
panic(err)
}
yamlContents = requiresApproversForPullRequests.YmlFile
if err = writeProbeFile("requiresApproversForPullRequests", yamlContents); err != nil {
panic(err)
}
yamlContents = requiresCodeOwnersReview.YmlFile
if err = writeProbeFile("requiresCodeOwnersReview", yamlContents); err != nil {
panic(err)
}
yamlContents = requiresLastPushApproval.YmlFile
if err = writeProbeFile("requiresLastPushApproval", yamlContents); err != nil {
panic(err)
}
yamlContents = requiresPRsToChangeCode.YmlFile
if err = writeProbeFile("requiresPRsToChangeCode", yamlContents); err != nil {
panic(err)
}
yamlContents = requiresUpToDateBranches.YmlFile
if err = writeProbeFile("requiresUpToDateBranches", yamlContents); err != nil {
panic(err)
}
yamlContents = runsStatusChecksBeforeMerging.YmlFile
if err = writeProbeFile("runsStatusChecksBeforeMerging", yamlContents); err != nil {
panic(err)
}
yamlContents = sastToolConfigured.YmlFile
if err = writeProbeFile("sastToolConfigured", yamlContents); err != nil {
panic(err)
}
yamlContents = sastToolRunsOnAllCommits.YmlFile
if err = writeProbeFile("sastToolRunsOnAllCommits", yamlContents); err != nil {
panic(err)
}
yamlContents = securityPolicyContainsLinks.YmlFile
if err = writeProbeFile("securityPolicyContainsLinks", yamlContents); err != nil {
panic(err)
}
yamlContents = securityPolicyContainsText.YmlFile
if err = writeProbeFile("securityPolicyContainsText", yamlContents); err != nil {
panic(err)
}
yamlContents = securityPolicyContainsVulnerabilityDisclosure.YmlFile
if err = writeProbeFile("securityPolicyContainsVulnerabilityDisclosure", yamlContents); err != nil {
panic(err)
}
yamlContents = securityPolicyPresent.YmlFile
if err = writeProbeFile("securityPolicyPresent", yamlContents); err != nil {
panic(err)
}
yamlContents = testsRunInCI.YmlFile
if err = writeProbeFile("testsRunInCI", yamlContents); err != nil {
panic(err)
}
yamlContents = topLevelPermissions.YmlFile
if err = writeProbeFile("topLevelPermissions", yamlContents); err != nil {
panic(err)
}
yamlContents = unsafeblock.YmlFile
if err = writeProbeFile("unsafeblock", yamlContents); err != nil {
panic(err)
}
yamlContents = webhooksUseSecrets.YmlFile
if err = writeProbeFile("webhooksUseSecrets", yamlContents); err != nil {
panic(err)
}
}
func FuzzProbes(f *testing.F) {
f.Fuzz(func(t *testing.T, callType int, data []byte) {
fdp := gfh.NewConsumer(data)
switch callType % 31 {
case 0:
fuzzers := make([]checker.Tool, 0)
fdp.GenerateStruct(&fuzzers)
if len(fuzzers) == 0 {
return
}
r := &checker.RawResults{
FuzzingResults: checker.FuzzingData{
Fuzzers: fuzzers,
},
}
_, _, err := fuzzed.Run(r)
if err != nil {
panic(err)
}
case 1:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = blocksDeleteOnBranches.Run(r)
case 2:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = branchProtectionAppliesToAdmins.Run(r)
case 3:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = branchesAreProtected.Run(r)
case 4:
defaultBranchChangesets := make([]checker.Changeset, 0)
fdp.GenerateStruct(&defaultBranchChangesets)
if len(defaultBranchChangesets) == 0 {
return
}
r := &checker.RawResults{
CodeReviewResults: checker.CodeReviewData{
DefaultBranchChangesets: defaultBranchChangesets,
},
}
_, _, _ = codeApproved.Run(r)
case 5:
defaultBranchChangesets := make([]checker.Changeset, 0)
fdp.GenerateStruct(&defaultBranchChangesets)
if len(defaultBranchChangesets) == 0 {
return
}
r := &checker.RawResults{
CodeReviewResults: checker.CodeReviewData{
DefaultBranchChangesets: defaultBranchChangesets,
},
}
_, _, _ = codeReviewOneReviewers.Run(r)
case 6:
users := make([]clients.User, 0)
fdp.GenerateStruct(&users)
if len(users) == 0 {
return
}
r := &checker.RawResults{
ContributorsResults: checker.ContributorsData{
Users: users,
},
}
_, _, _ = contributorsFromOrgOrCompany.Run(r)
case 7:
tools := make([]checker.Tool, 0)
fdp.GenerateStruct(&tools)
r := &checker.RawResults{
DependencyUpdateToolResults: checker.DependencyUpdateToolData{
Tools: tools,
},
}
_, _, _ = dependencyUpdateToolConfigured.Run(r)
case 8:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = dismissesStaleReviews.Run(r)
case 9:
files := make([]checker.File, 0)
fdp.GenerateStruct(&files)
if len(files) == 0 {
return
}
r := &checker.RawResults{
BinaryArtifactResults: checker.BinaryArtifactData{
Files: files,
},
}
_, _, _ = hasBinaryArtifacts.Run(r)
case 10:
files := make([]checker.File, 0)
fdp.GenerateStruct(&files)
if len(files) == 0 {
return
}
r := &checker.RawResults{
BinaryArtifactResults: checker.BinaryArtifactData{
Files: files,
},
}
_, _, _ = hasUnverifiedBinaryArtifacts.Run(r)
case 11:
workflows := make([]checker.DangerousWorkflow, 0)
fdp.GenerateStruct(&workflows)
if len(workflows) == 0 {
return
}
// Create temp file
fileContents, err := fdp.GetBytes()
if err != nil {
return
}
tmpDir := t.TempDir()
err = os.WriteFile(filepath.Join(tmpDir, "workflowPath.yml"), fileContents, 0755)
if err != nil {
// Panic as this should not happen and it may block the fuzzer
// if it fails here.
panic(err)
}
r := &checker.RawResults{
DangerousWorkflowResults: checker.DangerousWorkflowData{
NumWorkflows: len(workflows),
Workflows: workflows,
},
Metadata: checker.MetadataData{
Metadata: map[string]string{
"localPath": filepath.Join(tmpDir, "workflowPath.yml"),
},
},
}
_, _, _ = hasDangerousWorkflowScriptInjection.Run(r)
case 12:
workflows := make([]checker.DangerousWorkflow, 0)
fdp.GenerateStruct(&workflows)
if len(workflows) == 0 {
return
}
// Create temp file
fileContents, err := fdp.GetBytes()
if err != nil {
return
}
tmpDir := t.TempDir()
err = os.WriteFile(filepath.Join(tmpDir, "workflowPath.yml"), fileContents, 0755)
if err != nil {
// Panic as this should not happen and it may block the fuzzer
// if it fails here.
panic(err)
}
r := &checker.RawResults{
DangerousWorkflowResults: checker.DangerousWorkflowData{
NumWorkflows: len(workflows),
Workflows: workflows,
},
Metadata: checker.MetadataData{
Metadata: map[string]string{
"localPath": filepath.Join(tmpDir, "workflowPath.yml"),
},
},
}
_, _, _ = hasDangerousWorkflowUntrustedCheckout.Run(r)
case 13:
licenseFiles := make([]checker.LicenseFile, 0)
fdp.GenerateStruct(&licenseFiles)
if len(licenseFiles) == 0 {
return
}
r := &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: licenseFiles,
},
}
_, _, _ = hasPermissiveLicense.Run(r)
case 14:
sbomFiles := make([]checker.SBOM, 0)
fdp.GenerateStruct(&sbomFiles)
if len(sbomFiles) == 0 {
return
}
r := &checker.RawResults{
SBOMResults: checker.SBOMData{
SBOMFiles: sbomFiles,
},
}
hasReleaseSBOM.Run(r)
case 15:
sbomFiles := make([]checker.SBOM, 0)
fdp.GenerateStruct(&sbomFiles)
if len(sbomFiles) == 0 {
return
}
r := &checker.RawResults{
SBOMResults: checker.SBOMData{
SBOMFiles: sbomFiles,
},
}
hasSBOM.Run(r)
case 16:
issues := make([]clients.Issue, 0)
fdp.GenerateStruct(&issues)
if len(issues) == 0 {
return
}
commits := make([]clients.Commit, 0)
fdp.GenerateStruct(&commits)
if len(commits) == 0 {
return
}
r := &checker.RawResults{
MaintainedResults: checker.MaintainedData{
CreatedAt: time.Now(),
Issues: issues,
DefaultBranchCommits: commits,
ArchivedStatus: checker.ArchivedStatus{
Status: false,
},
},
}
issueActivityByProjectMember.Run(r)
case 17:
permissions := make([]checker.TokenPermission, 0)
fdp.GenerateStruct(&permissions)
if len(permissions) == 0 {
return
}
r := &checker.RawResults{
TokenPermissionsResults: checker.TokenPermissionsData{
TokenPermissions: permissions,
NumTokens: len(permissions),
},
}
jobLevelPermissions.Run(r)
case 18:
packages := make([]checker.Package, 0)
fdp.GenerateStruct(&packages)
if len(packages) == 0 {
return
}
r := &checker.RawResults{
PackagingResults: checker.PackagingData{
Packages: packages,
},
}
packagedWithAutomatedWorkflow.Run(r)
case 19:
dependencies := make([]checker.Dependency, 0)
fdp.GenerateStruct(&dependencies)
if len(dependencies) == 0 {
return
}
processingErrors := make([]checker.ElementError, 0)
fdp.GenerateStruct(&processingErrors)
if len(processingErrors) == 0 {
return
}
r := &checker.RawResults{
PinningDependenciesResults: checker.PinningDependenciesData{
Dependencies: dependencies,
ProcessingErrors: processingErrors,
},
}
pinsDependencies.Run(r)
case 20:
releases := make([]clients.Release, 0)
fdp.GenerateStruct(&releases)
if len(releases) == 0 {
return
}
r := &checker.RawResults{
SignedReleasesResults: checker.SignedReleasesData{
Releases: releases,
},
}
releasesAreSigned.Run(r)
case 21:
releases := make([]clients.Release, 0)
fdp.GenerateStruct(&releases)
if len(releases) == 0 {
return
}
r := &checker.RawResults{
SignedReleasesResults: checker.SignedReleasesData{
Releases: releases,
},
}
releasesHaveProvenance.Run(r)
case 22:
packages := make([]checker.ProjectPackage, 0)
fdp.GenerateStruct(&packages)
if len(packages) == 0 {
return
}
r := &checker.RawResults{
SignedReleasesResults: checker.SignedReleasesData{
Packages: packages,
},
}
releasesHaveVerifiedProvenance.Run(r)
case 23:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = requiresApproversForPullRequests.Run(r)
case 24:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = requiresCodeOwnersReview.Run(r)
case 25:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = requiresLastPushApproval.Run(r)
case 26:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = requiresPRsToChangeCode.Run(r)
case 27:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = requiresUpToDateBranches.Run(r)
case 28:
r, err := createRawBranchProtectionsData(fdp)
if err != nil {
return
}
_, _, _ = runsStatusChecksBeforeMerging.Run(r)
case 29:
workflows := make([]checker.SASTWorkflow, 0)
fdp.GenerateStruct(&workflows)
if len(workflows) == 0 {
return
}
commits := make([]checker.SASTCommit, 0)
fdp.GenerateStruct(&commits)
if len(commits) == 0 {
return
}
r := &checker.RawResults{
SASTResults: checker.SASTData{
Workflows: workflows,
Commits: commits,
NumWorkflows: len(workflows),
},
}
_, _, _ = sastToolConfigured.Run(r)
_, _, _ = sastToolRunsOnAllCommits.Run(r)
case 30:
ciInfo := make([]checker.RevisionCIInfo, 0)
fdp.GenerateStruct(&ciInfo)
if len(ciInfo) == 0 {
return
}
r := &checker.RawResults{
CITestResults: checker.CITestData{
CIInfo: ciInfo,
},
}
_, _, _ = testsRunInCI.Run(r)
}
})
}
func createRawBranchProtectionsData(fdp *gfh.ConsumeFuzzer) (*checker.RawResults, error) {
branches := make([]clients.BranchRef, 0)
fdp.GenerateStruct(&branches)
if len(branches) == 0 {
return nil, fmt.Errorf("created no branches")
}
for _, branch := range branches {
if branch.Name == nil {
return nil, fmt.Errorf("created branch with nil name")
}
}
bpd := checker.BranchProtectionsData{
Branches: branches,
}
r := &checker.RawResults{
BranchProtectionResults: bpd,
}
return r, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package releasesAreSigned
import (
"embed"
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SignedReleases})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "releasesAreSigned"
ReleaseNameKey = "releaseName"
AssetNameKey = "assetName"
releaseLookBack = 5
)
var signatureExtensions = []string{".asc", ".minisig", ".sig", ".sign", ".sigstore"}
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
releases := raw.SignedReleasesResults.Releases
totalReleases := 0
for releaseIndex, release := range releases {
if releaseIndex >= releaseLookBack {
break
}
if len(release.Assets) == 0 {
continue
}
totalReleases++
signed := false
for j := range release.Assets {
asset := release.Assets[j]
for _, suffix := range signatureExtensions {
if !strings.HasSuffix(asset.Name, suffix) {
continue
}
// Create True Finding
// with file info
loc := &finding.Location{
Type: finding.FileTypeURL,
Path: asset.URL,
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("signed release artifact: %s", asset.Name),
loc,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f.Values = map[string]string{
ReleaseNameKey: release.TagName,
AssetNameKey: asset.Name,
}
findings = append(findings, *f)
signed = true
break
}
if signed {
break
}
}
if signed {
continue
}
// Release is not signed
loc := &finding.Location{
Type: finding.FileTypeURL,
Path: release.URL,
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("release artifact %s not signed", release.TagName),
loc,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ReleaseNameKey, release.TagName)
findings = append(findings, *f)
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"no GitHub/GitLab releases found",
nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package releasesHaveProvenance
import (
"embed"
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SignedReleases})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "releasesHaveProvenance"
ReleaseNameKey = "releaseName"
AssetNameKey = "assetName"
releaseLookBack = 5
)
var provenanceExtensions = []string{".intoto.jsonl"}
//nolint:gocognit // bug hotfix
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
releases := raw.SignedReleasesResults.Releases
totalReleases := 0
for i := range releases {
release := releases[i]
if i >= releaseLookBack {
break
}
if len(release.Assets) == 0 {
continue
}
totalReleases++
hasProvenance := false
for j := range release.Assets {
asset := release.Assets[j]
for _, suffix := range provenanceExtensions {
if !strings.HasSuffix(asset.Name, suffix) {
continue
}
// Create True Finding
// with file info
loc := &finding.Location{
Type: finding.FileTypeURL,
Path: asset.URL,
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("provenance for release artifact: %s", asset.Name),
loc,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f.Values = map[string]string{
ReleaseNameKey: release.TagName,
AssetNameKey: asset.Name,
}
findings = append(findings, *f)
hasProvenance = true
break
}
if hasProvenance {
break
}
}
if hasProvenance {
continue
}
// Release does not have provenance
loc := &finding.Location{
Type: finding.FileTypeURL,
Path: release.URL,
}
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("release artifact %s does not have provenance", release.TagName),
loc,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ReleaseNameKey, release.TagName)
findings = append(findings, *f)
if totalReleases >= releaseLookBack {
break
}
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"no GitHub releases found",
nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package releasesHaveVerifiedProvenance
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SignedReleases})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "releasesHaveVerifiedProvenance"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
var findings []finding.Finding
if len(raw.SignedReleasesResults.Packages) == 0 {
f, err := finding.NewNotApplicable(fs, Probe, "no package manager releases found", nil)
if err != nil {
return []finding.Finding{}, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range raw.SignedReleasesResults.Packages {
p := raw.SignedReleasesResults.Packages[i]
if !p.Provenance.IsVerified {
f, err := finding.NewFalse(fs, Probe, "release without verified provenance", nil)
if err != nil {
return []finding.Finding{}, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
}
f, err := finding.NewTrue(fs, Probe, "release with verified provenance", nil)
if err != nil {
return []finding.Finding{}, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package requiresApproversForPullRequests
import (
"embed"
"errors"
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "requiresApproversForPullRequests"
BranchNameKey = "branchName"
RequiredReviewersKey = "numberOfRequiredReviewers"
)
var errWrongValue = errors.New("wrong value, should not happen")
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
nilMsg := fmt.Sprintf("could not determine whether branch '%s' has required approving review count", *branch.Name)
falseMsg := fmt.Sprintf("branch '%s' does not require approvers", *branch.Name)
p := branch.BranchProtectionRule.PullRequestRule.RequiredApprovingReviewCount
f, err := finding.NewWith(fs, Probe, "", nil, finding.OutcomeNotAvailable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
switch {
case p == nil:
f = f.WithMessage(nilMsg).WithOutcome(finding.OutcomeNotAvailable)
case *p > 0:
msg := fmt.Sprintf("required approving review count is %d on branch '%s'", *p, *branch.Name)
f = f.WithMessage(msg).WithOutcome(finding.OutcomeTrue)
f = f.WithValue(RequiredReviewersKey, strconv.Itoa(int(*p)))
case *p == 0:
f = f.WithMessage(falseMsg).WithOutcome(finding.OutcomeFalse)
f = f.WithValue(RequiredReviewersKey, strconv.Itoa(int(*p)))
default:
return nil, Probe, fmt.Errorf("create finding: %w", errWrongValue)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package requiresCodeOwnersReview
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "requiresCodeOwnersReview"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
reqOwnerReviews := branch.BranchProtectionRule.PullRequestRule.RequireCodeOwnerReviews
var text string
var outcome finding.Outcome
switch {
case reqOwnerReviews == nil:
text = "could not determine whether codeowners review is allowed"
outcome = finding.OutcomeNotAvailable
case !*reqOwnerReviews:
text = fmt.Sprintf("codeowners review is not required on branch '%s'", *branch.Name)
outcome = finding.OutcomeFalse
case len(r.CodeownersFiles) == 0:
text = "codeowners review is required - but no codeowners file found in repo"
outcome = finding.OutcomeFalse
default:
text = fmt.Sprintf("codeowner review is required on branch '%s'", *branch.Name)
outcome = finding.OutcomeTrue
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package requiresLastPushApproval
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/branchprotection"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "requiresLastPushApproval"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
p := branch.BranchProtectionRule.RequireLastPushApproval
text, outcome, err := branchprotection.GetTextOutcomeFromBool(p, "last push approval", *branch.Name)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package requiresPRsToChangeCode
import (
"embed"
"errors"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "requiresPRsToChangeCode"
BranchNameKey = "branchName"
)
var errWrongValue = errors.New("wrong value, should not happen")
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
nilMsg := fmt.Sprintf("could not determine whether branch '%s' requires PRs to change code", *branch.Name)
trueMsg := fmt.Sprintf("PRs are required in order to make changes on branch '%s'", *branch.Name)
falseMsg := fmt.Sprintf("PRs are not required to make changes on branch '%s'; ", *branch.Name) +
"or we don't have data to detect it." +
"If you think it might be the latter, make sure to run Scorecard with a PAT or use Repo " +
"Rules (that are always public) instead of Branch Protection settings"
p := branch.BranchProtectionRule.PullRequestRule.Required
f, err := finding.NewWith(fs, Probe, "", nil, finding.OutcomeNotAvailable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
switch {
case p == nil:
f = f.WithMessage(nilMsg).WithOutcome(finding.OutcomeNotAvailable)
case *p:
f = f.WithMessage(trueMsg).WithOutcome(finding.OutcomeTrue)
case !*p:
f = f.WithMessage(falseMsg).WithOutcome(finding.OutcomeFalse)
default:
return nil, Probe, fmt.Errorf("create finding: %w", errWrongValue)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package requiresUpToDateBranches
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/branchprotection"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "requiresUpToDateBranches"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
p := branch.BranchProtectionRule.CheckRules.UpToDateBeforeMerge
text, outcome, err := branchprotection.GetTextOutcomeFromBool(p,
"up-to-date branches",
*branch.Name)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f, err := finding.NewWith(fs, Probe, text, nil, outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package runsStatusChecksBeforeMerging
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.BranchProtection})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "runsStatusChecksBeforeMerging"
BranchNameKey = "branchName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.BranchProtectionResults
var findings []finding.Finding
if len(r.Branches) == 0 {
f, err := finding.NewWith(fs, Probe, "no branches found", nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range r.Branches {
branch := &r.Branches[i]
var f *finding.Finding
var err error
switch {
case len(branch.BranchProtectionRule.CheckRules.Contexts) > 0:
f, err = finding.NewWith(fs, Probe,
fmt.Sprintf("status check found to merge onto on branch '%s'", *branch.Name), nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
default:
f, err = finding.NewWith(fs, Probe,
fmt.Sprintf("no status checks found to merge onto branch '%s'", *branch.Name), nil,
finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
}
f = f.WithValue(BranchNameKey, *branch.Name)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package sastToolConfigured
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SAST})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "sastToolConfigured"
ToolKey = "tool"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.SASTResults
if len(r.Workflows) == 0 {
f, err := finding.NewWith(fs, Probe, "no SAST configuration files detected", nil, finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
findings := make([]finding.Finding, len(r.Workflows))
for i := range r.Workflows {
tool := string(r.Workflows[i].Type)
loc := r.Workflows[i].File.Location()
f, err := finding.NewWith(fs, Probe, "SAST configuration detected: "+tool, loc, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(ToolKey, tool)
findings[i] = *f
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package sastToolRunsOnAllCommits
import (
"embed"
"fmt"
"strconv"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SAST})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "sastToolRunsOnAllCommits"
// TotalPRsKey is the Values map key which specifies the total number of PRs being evaluated.
TotalPRsKey = "totalPullRequestsMerged"
// AnalyzedPRsKey is the Values map key which specifies the number of PRs analyzed by a SAST.
AnalyzedPRsKey = "totalPullRequestsAnalyzed"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.SASTResults
f, err := finding.NewWith(fs, Probe,
"", nil,
finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
totalPullRequestsMerged := len(r.Commits)
totalPullRequestsAnalyzed := 0
for i := range r.Commits {
wf := &r.Commits[i]
if wf.Compliant {
totalPullRequestsAnalyzed++
}
}
if totalPullRequestsMerged == 0 {
f = f.WithOutcome(finding.OutcomeNotApplicable)
f = f.WithMessage("no pull requests merged into dev branch")
return []finding.Finding{*f}, Probe, nil
}
f = f.WithValue(AnalyzedPRsKey, strconv.Itoa(totalPullRequestsAnalyzed))
f = f.WithValue(TotalPRsKey, strconv.Itoa(totalPullRequestsMerged))
if totalPullRequestsAnalyzed == totalPullRequestsMerged {
msg := fmt.Sprintf("all commits (%v) are checked with a SAST tool", totalPullRequestsMerged)
f = f.WithOutcome(finding.OutcomeTrue).WithMessage(msg)
} else {
msg := fmt.Sprintf("%v commits out of %v are checked with a SAST tool",
totalPullRequestsAnalyzed, totalPullRequestsMerged)
f = f.WithOutcome(finding.OutcomeFalse).WithMessage(msg)
}
return []finding.Finding{*f}, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package securityPolicyContainsLinks
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/secpolicy"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecurityPolicy})
}
//go:embed *.yml
var fs embed.FS
const Probe = "securityPolicyContainsLinks"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
policies := raw.SecurityPolicyResults.PolicyFiles
for i := range policies {
policy := &policies[i]
emails := secpolicy.CountSecInfo(policy.Information, checker.SecurityPolicyInformationTypeEmail, true)
urls := secpolicy.CountSecInfo(policy.Information, checker.SecurityPolicyInformationTypeLink, true)
if (urls + emails) > 0 {
f, err := finding.NewTrue(fs, Probe,
"Found linked content", policy.File.Location())
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
} else {
f, err := finding.NewFalse(fs, Probe,
"no linked content found", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
if len(findings) == 0 {
f, err := finding.NewFalse(fs, Probe, "no security file to analyze", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package securityPolicyContainsText
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/secpolicy"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecurityPolicy})
}
//go:embed *.yml
var fs embed.FS
const Probe = "securityPolicyContainsText"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
policies := raw.SecurityPolicyResults.PolicyFiles
for i := range policies {
policy := &policies[i]
linkedContentLen := 0
emails := secpolicy.CountSecInfo(policy.Information, checker.SecurityPolicyInformationTypeEmail, true)
urls := secpolicy.CountSecInfo(policy.Information, checker.SecurityPolicyInformationTypeLink, true)
for _, i := range secpolicy.FindSecInfo(policy.Information, checker.SecurityPolicyInformationTypeEmail, true) {
linkedContentLen += len(i.InformationValue.Match)
}
for _, i := range secpolicy.FindSecInfo(policy.Information, checker.SecurityPolicyInformationTypeLink, true) {
linkedContentLen += len(i.InformationValue.Match)
}
if policy.File.FileSize > 1 && (policy.File.FileSize > uint(linkedContentLen+((urls+emails)*2))) {
f, err := finding.NewTrue(fs, Probe,
"Found text in security policy", policy.File.Location())
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
} else {
f, err := finding.NewFalse(fs, Probe,
"No text (besides links / emails) found in security policy", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
if len(findings) == 0 {
f, err := finding.NewFalse(fs, Probe, "no security file to analyze", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package securityPolicyContainsVulnerabilityDisclosure
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/secpolicy"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecurityPolicy})
}
//go:embed *.yml
var fs embed.FS
const Probe = "securityPolicyContainsVulnerabilityDisclosure"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
policies := raw.SecurityPolicyResults.PolicyFiles
for i := range policies {
policy := &policies[i]
discvuls := secpolicy.CountSecInfo(policy.Information, checker.SecurityPolicyInformationTypeText, false)
if discvuls > 1 {
f, err := finding.NewTrue(fs, Probe,
"Found disclosure, vulnerability, and/or timelines in security policy", policy.File.Location())
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
} else {
f, err := finding.NewFalse(fs, Probe,
"One or no descriptive hints of disclosure, vulnerability, and/or timelines in security policy", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
if len(findings) == 0 {
f, err := finding.NewFalse(fs, Probe, "no security file to analyze", nil)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package securityPolicyPresent
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SecurityPolicy})
}
//go:embed *.yml
var fs embed.FS
const Probe = "securityPolicyPresent"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var files []checker.File
for i := range raw.SecurityPolicyResults.PolicyFiles {
files = append(files, raw.SecurityPolicyResults.PolicyFiles[i].File)
}
var findings []finding.Finding
for i := range files {
file := &files[i]
f, err := finding.NewWith(fs, Probe, "security policy file detected",
file.Location(), finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithRemediationMetadata(raw.Metadata.Metadata)
findings = append(findings, *f)
}
// No file found.
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe, "no security policy file detected",
nil, finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithRemediationMetadata(raw.Metadata.Metadata)
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package testsRunInCI
import (
"embed"
"fmt"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.CITests})
}
//go:embed *.yml
var fs embed.FS
const (
Probe = "testsRunInCI"
success = "success"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
var findings []finding.Finding
c := raw.CITestResults
if len(c.CIInfo) == 0 {
f, err := finding.NewWith(fs, Probe,
"no pull requests found", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for i := range c.CIInfo {
r := c.CIInfo[i]
// GitHub Statuses.
prSuccessStatus, f, err := prHasSuccessStatus(r)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
if prSuccessStatus {
findings = append(findings, *f)
continue
}
// GitHub Check Runs.
prCheckSuccessful, f, err := prHasSuccessfulCheck(r)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
if prCheckSuccessful {
findings = append(findings, *f)
}
if !prSuccessStatus && !prCheckSuccessful {
f, err := finding.NewWith(fs, Probe,
fmt.Sprintf("merged PR %d without CI test at HEAD: %s", r.PullRequestNumber, r.HeadSHA),
nil, finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
return findings, Probe, nil
}
// PR has a status marked 'success' and a CI-related context.
//
//nolint:unparam
func prHasSuccessStatus(r checker.RevisionCIInfo) (bool, *finding.Finding, error) {
for _, status := range r.Statuses {
if status.State != success {
continue
}
if isTest(status.Context) || isTest(status.TargetURL) {
msg := fmt.Sprintf("CI test found: pr: %s, context: %s", r.HeadSHA,
status.Context)
f, err := finding.NewWith(fs, Probe,
msg, nil,
finding.OutcomeTrue)
if err != nil {
return false, nil, fmt.Errorf("create finding: %w", err)
}
loc := &finding.Location{
Path: status.URL,
Type: finding.FileTypeURL,
}
f = f.WithLocation(loc)
return true, f, nil
}
}
return false, nil, nil
}
// PR has a successful CI-related check.
//
//nolint:unparam
func prHasSuccessfulCheck(r checker.RevisionCIInfo) (bool, *finding.Finding, error) {
for _, cr := range r.CheckRuns {
if cr.Status != "completed" {
continue
}
if cr.Conclusion != success {
continue
}
if isTest(cr.App.Slug) {
msg := fmt.Sprintf("CI test found: pr: %d, context: %s", r.PullRequestNumber,
cr.App.Slug)
f, err := finding.NewWith(fs, Probe,
msg, nil,
finding.OutcomeTrue)
if err != nil {
return false, nil, fmt.Errorf("create finding: %w", err)
}
loc := &finding.Location{
Path: cr.URL,
Type: finding.FileTypeURL,
}
f = f.WithLocation(loc)
return true, f, nil
}
}
return false, nil, nil
}
// isTest returns true if the given string is a CI test.
func isTest(s string) bool {
l := strings.ToLower(s)
// Add more patterns here!
for _, pattern := range []string{
"appveyor", "buildkite", "circleci", "e2e", "github-actions", "jenkins",
"mergeable", "packit-as-a-service", "semaphoreci", "test", "travis-ci",
"flutter-dashboard", "cirrus-ci", "Cirrus CI", "azure-pipelines", "ci/woodpecker",
"vstfs:///build/build",
} {
if strings.Contains(l, pattern) {
return true
}
}
return false
}
// Copyright 2024 OpenSSF 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.
//nolint:stylecheck
package topLevelPermissions
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/internal/utils/permissions"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const (
Probe = "topLevelPermissions"
PermissionLevelKey = "permissionLevel"
TokenNameKey = "tokenName"
)
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
results := raw.TokenPermissionsResults
var findings []finding.Finding
if results.NumTokens == 0 {
f, err := finding.NewWith(fs, Probe,
"No token permissions found",
nil, finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for _, r := range results.TokenPermissions {
if r.LocationType == nil {
continue
}
if *r.LocationType != checker.PermissionLocationTop {
continue
}
switch r.Type {
case checker.PermissionLevelNone:
f, err := permissions.CreateNoneFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
case checker.PermissionLevelUndeclared:
f, err := permissions.CreateUndeclaredFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
case checker.PermissionLevelRead:
f, err := permissions.ReadTrueLevelFinding(Probe, fs, r, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
continue
default:
// to satisfy linter
}
tokenName := ""
switch {
case r.Name == nil && r.Value == nil:
continue
case r.Value != nil && *r.Value == "write-all":
tokenName = *r.Value
case r.Name != nil:
tokenName = *r.Name
default:
continue
}
// Create finding
f, err := permissions.CreateFalseFinding(r, Probe, fs, raw.Metadata.Metadata)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithValue(PermissionLevelKey, string(r.Type))
f = f.WithValue(TokenNameKey, tokenName)
findings = append(findings, *f)
}
if len(findings) == 0 {
f, err := finding.NewWith(fs, Probe,
"no job-level permissions found",
nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
return findings, Probe, nil
}
// Copyright 2025 OpenSSF 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 unsafeblock
import (
"embed"
"fmt"
"go/parser"
"go/token"
"reflect"
"strings"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/checks/fileparser"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/dotnet/csproj"
"github.com/ossf/scorecard/v5/internal/probes"
)
//go:embed *.yml
var fs embed.FS
const (
Probe = "unsafeblock"
)
type languageMemoryCheckConfig struct {
funcPointer func(client *checker.CheckRequest) ([]finding.Finding, error)
Desc string
}
var languageMemorySafeSpecs = map[clients.LanguageName]languageMemoryCheckConfig{
clients.Go: {
funcPointer: checkGoUnsafePackage,
Desc: "Check if Go code uses the unsafe package",
},
clients.CSharp: {
funcPointer: checkDotnetAllowUnsafeBlocks,
Desc: "Check if C# code uses unsafe blocks",
},
}
func init() {
probes.MustRegisterIndependent(Probe, Run)
}
func Run(raw *checker.CheckRequest) (found []finding.Finding, probeName string, err error) {
repoLanguageChecks, err := getLanguageChecks(raw)
if err != nil {
return nil, Probe, err
}
findings := []finding.Finding{}
for _, lang := range repoLanguageChecks {
langFindings, err := lang.funcPointer(raw)
if err != nil {
return nil, Probe, fmt.Errorf("error while running function for language %s: %w", lang.Desc, err)
}
findings = append(findings, langFindings...)
}
var nonErrorFindings bool
for _, f := range findings {
if f.Outcome != finding.OutcomeError {
nonErrorFindings = true
}
}
// if we don't have any findings (ignoring OutcomeError), we think it's safe
if !nonErrorFindings {
found, err := finding.NewWith(fs, Probe,
"All supported ecosystems do not declare or use unsafe code blocks", nil, finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *found)
}
return findings, Probe, nil
}
func getLanguageChecks(raw *checker.CheckRequest) ([]languageMemoryCheckConfig, error) {
langs, err := raw.RepoClient.ListProgrammingLanguages()
if err != nil {
return nil, fmt.Errorf("cannot get langs of repo: %w", err)
}
if len(langs) == 1 && langs[0].Name == clients.All {
return getAllLanguages(), nil
}
ret := []languageMemoryCheckConfig{}
for _, language := range langs {
if lang, ok := languageMemorySafeSpecs[clients.LanguageName(strings.ToLower(string(language.Name)))]; ok {
ret = append(ret, lang)
}
}
return ret, nil
}
func getAllLanguages() []languageMemoryCheckConfig {
allLanguages := make([]languageMemoryCheckConfig, 0, len(languageMemorySafeSpecs))
for l := range languageMemorySafeSpecs {
allLanguages = append(allLanguages, languageMemorySafeSpecs[l])
}
return allLanguages
}
// Golang
func checkGoUnsafePackage(client *checker.CheckRequest) ([]finding.Finding, error) {
findings := []finding.Finding{}
if err := fileparser.OnMatchingFileContentDo(client.RepoClient, fileparser.PathMatcher{
Pattern: "*.go",
CaseSensitive: false,
}, goCodeUsesUnsafePackage, &findings); err != nil {
return nil, err
}
return findings, nil
}
func goCodeUsesUnsafePackage(path string, content []byte, args ...interface{}) (bool, error) {
findings, ok := args[0].(*[]finding.Finding)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type findings, got %v", reflect.TypeOf(args[0])))
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", content, parser.ImportsOnly)
if err != nil {
found, err := finding.NewWith(fs, Probe, "malformed golang file", &finding.Location{
Path: path,
}, finding.OutcomeError)
if err != nil {
return false, fmt.Errorf("create finding: %w", err)
}
*findings = append(*findings, *found)
return true, nil
}
for _, i := range f.Imports {
if i.Path.Value == `"unsafe"` {
lineStart := uint(fset.Position(i.Pos()).Line)
found, err := finding.NewWith(fs, Probe,
"Golang code uses the unsafe package", &finding.Location{
Path: path, LineStart: &lineStart,
}, finding.OutcomeTrue)
if err != nil {
return false, fmt.Errorf("create finding: %w", err)
}
*findings = append(*findings, *found)
}
}
return true, nil
}
// CSharp
func checkDotnetAllowUnsafeBlocks(client *checker.CheckRequest) ([]finding.Finding, error) {
findings := []finding.Finding{}
if err := fileparser.OnMatchingFileContentDo(client.RepoClient, fileparser.PathMatcher{
Pattern: "*.csproj",
CaseSensitive: false,
}, csProjAllosUnsafeBlocks, &findings); err != nil {
return nil, err
}
return findings, nil
}
func csProjAllosUnsafeBlocks(path string, content []byte, args ...interface{}) (bool, error) {
findings, ok := args[0].(*[]finding.Finding)
if !ok {
// panic if it is not correct type
panic(fmt.Sprintf("expected type findings, got %v", reflect.TypeOf(args[0])))
}
unsafe, err := csproj.IsAllowUnsafeBlocksEnabled(content)
if err != nil {
found, err := finding.NewWith(fs, Probe, "malformed csproj file", &finding.Location{
Path: path,
}, finding.OutcomeError)
if err != nil {
return false, fmt.Errorf("create finding: %w", err)
}
*findings = append(*findings, *found)
return true, nil
}
if unsafe {
found, err := finding.NewWith(fs, Probe,
"C# project file allows the use of unsafe blocks", &finding.Location{
Path: path,
}, finding.OutcomeTrue)
if err != nil {
return false, fmt.Errorf("create finding: %w", err)
}
*findings = append(*findings, *found)
}
return true, nil
}
// Copyright 2023 OpenSSF 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.
//nolint:stylecheck
package webhooksUseSecrets
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/internal/checknames"
"github.com/ossf/scorecard/v5/internal/probes"
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
)
func init() {
probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.Webhooks})
}
//go:embed *.yml
var fs embed.FS
const Probe = "webhooksUseSecrets"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
r := raw.WebhookResults
var findings []finding.Finding
if len(r.Webhooks) == 0 {
f, err := finding.NewWith(fs, Probe,
"Repository does not have webhooks.", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
}
for _, hook := range r.Webhooks {
if hook.UsesAuthSecret {
msg := "Webhook with token authorization found."
f, err := finding.NewWith(fs, Probe,
msg, nil, finding.OutcomeTrue)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: hook.Path,
})
findings = append(findings, *f)
} else {
msg := "Webhook without token authorization found."
f, err := finding.NewWith(fs, Probe,
msg, nil, finding.OutcomeFalse)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
f = f.WithLocation(&finding.Location{
Path: hook.Path,
})
findings = append(findings, *f)
}
}
return findings, Probe, nil
}
// Copyright 2023 OpenSSF 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 zrunner
import (
"errors"
"fmt"
"github.com/ossf/scorecard/v5/checker"
serrors "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes"
)
var errProbeRun = errors.New("probe run failure")
// Run runs the probes in probesToRun.
func Run(raw *checker.RawResults, probesToRun []probes.ProbeImpl) ([]finding.Finding, error) {
var results []finding.Finding
var errs []error
for _, probeFunc := range probesToRun {
findings, probeID, err := probeFunc(raw)
if err != nil {
errs = append(errs, err)
results = append(results,
finding.Finding{
Probe: probeID,
Outcome: finding.OutcomeError,
Message: serrors.WithMessage(serrors.ErrScorecardInternal, err.Error()).Error(),
})
continue
}
results = append(results, findings...)
}
if len(errs) > 0 {
return results, fmt.Errorf("%w: %v", errProbeRun, errs)
}
return results, nil
}
// Copyright 2022 OpenSSF 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 remediation
import (
"errors"
"fmt"
"strings"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/finding"
)
var errInvalidArg = errors.New("invalid argument")
var (
workflowText = "update your workflow using https://app.stepsecurity.io/secureworkflow/%s/%s/%s?enable=%s"
//nolint:lll
workflowMarkdown = "update your workflow using [https://app.stepsecurity.io](https://app.stepsecurity.io/secureworkflow/%s/%s/%s?enable=%s)"
dockerfilePinText = "pin your Docker image by updating %[1]s to %[1]s@%s"
)
// TODO fix how this info makes it checks/evaluation.
type RemediationMetadata struct {
Branch string
Repo string
}
// New returns remediation relevant metadata from a CheckRequest.
func New(c *checker.CheckRequest) (*RemediationMetadata, error) {
if c == nil || c.RepoClient == nil {
return &RemediationMetadata{}, nil
}
// Get the branch for remediation.
branch, err := c.RepoClient.GetDefaultBranchName()
if err != nil {
return &RemediationMetadata{}, fmt.Errorf("GetDefaultBranchName: %w", err)
}
uri := c.RepoClient.URI()
parts := strings.Split(uri, "/")
if len(parts) != 3 {
return &RemediationMetadata{}, fmt.Errorf("%w: empty: %s", errInvalidArg, uri)
}
repo := fmt.Sprintf("%s/%s", parts[1], parts[2])
return &RemediationMetadata{Branch: branch, Repo: repo}, nil
}
// CreateWorkflowPinningRemediation create remediation for pinning GH Actions.
func (r *RemediationMetadata) CreateWorkflowPinningRemediation(filepath string) *finding.Remediation {
return r.createWorkflowRemediation(filepath, "pin")
}
func (r *RemediationMetadata) createWorkflowRemediation(path, t string) *finding.Remediation {
p := strings.TrimPrefix(path, ".github/workflows/")
if r.Branch == "" || r.Repo == "" {
return nil
}
text := fmt.Sprintf(workflowText, r.Repo, p, r.Branch, t)
markdown := fmt.Sprintf(workflowMarkdown, r.Repo, p, r.Branch, t)
return &finding.Remediation{
Text: text,
Markdown: markdown,
}
}
func dockerImageName(d *checker.Dependency) (name string, ok bool) {
if d.Name == nil || *d.Name == "" {
return "", false
}
if d.PinnedAt != nil && *d.PinnedAt != "" {
return fmt.Sprintf("%s:%s", *d.Name, *d.PinnedAt), true
}
return *d.Name, true
}
type Digester interface{ Digest(string) (string, error) }
type CraneDigester struct{}
func (c CraneDigester) Digest(name string) (string, error) {
//nolint:wrapcheck // error value not used
return crane.Digest(name)
}
// CreateDockerfilePinningRemediation create remediation for pinning Dockerfile images.
func CreateDockerfilePinningRemediation(dep *checker.Dependency, digester Digester) *finding.Remediation {
name, ok := dockerImageName(dep)
if !ok {
return nil
}
hash, err := digester.Digest(name)
if err != nil {
return nil
}
text := fmt.Sprintf(dockerfilePinText, name, hash)
markdown := text
return &finding.Remediation{
Text: text,
Markdown: markdown,
}
}