diff --git a/checker/check_runner.go b/checker/check_runner.go index 7c4e7ad3..df80e715 100644 --- a/checker/check_runner.go +++ b/checker/check_runner.go @@ -36,6 +36,30 @@ type Runner struct { 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 @@ -79,12 +103,11 @@ func (r *Runner) Run(ctx context.Context, c Check) CheckResult { startTime := time.Now() var res CheckResult - var l logger + l := NewLogger() for retriesRemaining := checkRetries; retriesRemaining > 0; retriesRemaining-- { checkRequest := r.CheckRequest checkRequest.Ctx = ctx - l = logger{} - checkRequest.Dlogger = &l + checkRequest.Dlogger = l res = c.Fn(&checkRequest) if res.Error2 != nil && errors.Is(res.Error2, sce.ErrRepoUnreachable) { checkRequest.Dlogger.Warn(&LogMessage{ diff --git a/checker/client.go b/checker/client.go new file mode 100644 index 00000000..986b8172 --- /dev/null +++ b/checker/client.go @@ -0,0 +1,66 @@ +// Copyright 2022 Security Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import ( + "context" + "fmt" + + "github.com/ossf/scorecard/v4/clients" + ghrepo "github.com/ossf/scorecard/v4/clients/githubrepo" + "github.com/ossf/scorecard/v4/clients/localdir" + "github.com/ossf/scorecard/v4/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 + error) { + var githubRepo clients.Repo + if localURI != "" { + localRepo, errLocal := localdir.MakeLocalDirRepo(localURI) + return localRepo, /*repo*/ + localdir.CreateLocalDirClient(ctx, logger), /*repoClient*/ + nil, /*ossFuzzClient*/ + nil, /*ciiClient*/ + nil, /*vulnClient*/ + fmt.Errorf("getting local directory client: %w", errLocal) + } + + githubRepo, errGitHub := ghrepo.MakeGithubRepo(repoURI) + if errGitHub != nil { + return githubRepo, + nil, + nil, + nil, + nil, + fmt.Errorf("getting local directory client: %w", errGitHub) + } + + ossFuzzRepoClient, errOssFuzz := ghrepo.CreateOssFuzzRepoClient(ctx, logger) + + // TODO(repo): Should we be handling the OSS-Fuzz client error like this? + return githubRepo, /*repo*/ + ghrepo.CreateGithubRepoClient(ctx, logger), /*repoClient*/ + ossFuzzRepoClient, /*ossFuzzClient*/ + clients.DefaultCIIBestPracticesClient(), /*ciiClient*/ + clients.DefaultVulnerabilitiesClient(), /*vulnClient*/ + fmt.Errorf("getting OSS-Fuzz repo client: %w", errOssFuzz) +} diff --git a/checker/detail_logger_impl.go b/checker/detail_logger_impl.go index 6aa1e3c1..e93da1cf 100644 --- a/checker/detail_logger_impl.go +++ b/checker/detail_logger_impl.go @@ -14,10 +14,17 @@ 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, @@ -26,6 +33,7 @@ func (l *logger) Info(msg *LogMessage) { l.logs = append(l.logs, cd) } +// Warn emits warn level logs. func (l *logger) Warn(msg *LogMessage) { cd := CheckDetail{ Type: DetailWarn, @@ -34,6 +42,7 @@ func (l *logger) Warn(msg *LogMessage) { l.logs = append(l.logs, cd) } +// Debug emits debug level logs. func (l *logger) Debug(msg *LogMessage) { cd := CheckDetail{ Type: DetailDebug, @@ -42,8 +51,14 @@ func (l *logger) Debug(msg *LogMessage) { l.logs = append(l.logs, cd) } +// Flush returns existing logs and resets the logger instance. func (l *logger) Flush() []CheckDetail { - ret := l.logs + ret := l.Logs() l.logs = nil return ret } + +// Logs returns existing logs. +func (l *logger) Logs() []CheckDetail { + return l.logs +} diff --git a/checks/all_checks.go b/checks/all_checks.go index bb4942f2..f04be1f8 100644 --- a/checks/all_checks.go +++ b/checks/all_checks.go @@ -22,6 +22,14 @@ import ( // AllChecks is the list of all security checks that will be run. var AllChecks = checker.CheckNameToFnMap{} +// GetAll returns the full list of checks, given any environment variable +// constraints. +// TODO(checks): Is this actually necessary given `AllChecks` exists? +func GetAll() checker.CheckNameToFnMap { + possibleChecks := AllChecks + return possibleChecks +} + func registerCheck(name string, fn checker.CheckFn, supportedRequestTypes []checker.RequestType) error { if name == "" { return errInternalNameCannotBeEmpty diff --git a/clients/githubrepo/client.go b/clients/githubrepo/client.go index a1ed069c..c164d666 100644 --- a/clients/githubrepo/client.go +++ b/clients/githubrepo/client.go @@ -228,7 +228,7 @@ func CreateGithubRepoClient(ctx context.Context, logger *log.Logger) clients.Rep 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 githubrepo.MakeGithubRepo: %w", err) + return nil, fmt.Errorf("error during MakeGithubRepo: %w", err) } ossFuzzRepoClient := CreateGithubRepoClient(ctx, logger) diff --git a/cmd/flags.go b/cmd/flags.go deleted file mode 100644 index 965e3006..00000000 --- a/cmd/flags.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2020 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 cmd implements Scorecard commandline. -package cmd - -var ( - flagRepo string - flagLocal string - flagCommit string - flagChecksToRun []string - flagMetadata []string - flagLogLevel string - flagFormat string - flagNPM string - flagPyPI string - flagRubyGems string - flagShowDetails bool - flagPolicyFile string -) diff --git a/cmd/root.go b/cmd/root.go index a2a6d363..5d8db411 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,23 +28,14 @@ import ( "github.com/ossf/scorecard/v4/checker" "github.com/ossf/scorecard/v4/checks" "github.com/ossf/scorecard/v4/clients" - "github.com/ossf/scorecard/v4/clients/githubrepo" - "github.com/ossf/scorecard/v4/clients/localdir" docs "github.com/ossf/scorecard/v4/docs/checks" - sce "github.com/ossf/scorecard/v4/errors" sclog "github.com/ossf/scorecard/v4/log" + "github.com/ossf/scorecard/v4/options" "github.com/ossf/scorecard/v4/pkg" - spol "github.com/ossf/scorecard/v4/policy" + "github.com/ossf/scorecard/v4/policy" ) const ( - formatJSON = "json" - formatSarif = "sarif" - formatDefault = "default" - formatRaw = "raw" - - cliEnableSarif = "ENABLE_SARIF" - scorecardLong = "A program that shows security scorecard for an open source software." scorecardUse = `./scorecard [--repo=] [--local=folder] [--checks=check1,...] [--show-details] or ./scorecard --{npm,pypi,rubygems}= @@ -59,42 +50,45 @@ var rootCmd = &cobra.Command{ Run: scorecardCmd, } +var opts = options.New() + //nolint:gochecknoinits func init() { - rootCmd.Flags().StringVar(&flagRepo, "repo", "", "repository to check") - rootCmd.Flags().StringVar(&flagLocal, "local", "", "local folder to check") - rootCmd.Flags().StringVar(&flagCommit, "commit", clients.HeadSHA, "commit to analyze") + rootCmd.Flags().StringVar(&opts.Repo, "repo", "", "repository to check") + rootCmd.Flags().StringVar(&opts.Local, "local", "", "local folder to check") + rootCmd.Flags().StringVar(&opts.Commit, "commit", options.DefaultCommit, "commit to analyze") rootCmd.Flags().StringVar( - &flagLogLevel, + &opts.LogLevel, "verbosity", - sclog.DefaultLevel.String(), + options.DefaultLogLevel, "set the log level", ) rootCmd.Flags().StringVar( - &flagNPM, "npm", "", + &opts.NPM, "npm", "", "npm package to check, given that the npm package has a GitHub repository") rootCmd.Flags().StringVar( - &flagPyPI, "pypi", "", + &opts.PyPI, "pypi", "", "pypi package to check, given that the pypi package has a GitHub repository") rootCmd.Flags().StringVar( - &flagRubyGems, "rubygems", "", + &opts.RubyGems, "rubygems", "", "rubygems package to check, given that the rubygems package has a GitHub repository") rootCmd.Flags().StringSliceVar( - &flagMetadata, "metadata", []string{}, "metadata for the project. It can be multiple separated by commas") - rootCmd.Flags().BoolVar(&flagShowDetails, "show-details", false, "show extra details about each check") + &opts.Metadata, "metadata", []string{}, "metadata for the project. It can be multiple separated by commas") + rootCmd.Flags().BoolVar(&opts.ShowDetails, "show-details", false, "show extra details about each check") checkNames := []string{} - for checkName := range getAllChecks() { + for checkName := range checks.GetAll() { checkNames = append(checkNames, checkName) } - rootCmd.Flags().StringSliceVar(&flagChecksToRun, "checks", []string{}, + rootCmd.Flags().StringSliceVar(&opts.ChecksToRun, "checks", []string{}, fmt.Sprintf("Checks to run. Possible values are: %s", strings.Join(checkNames, ","))) - if isSarifEnabled() { - rootCmd.Flags().StringVar(&flagPolicyFile, "policy", "", "policy to enforce") - rootCmd.Flags().StringVar(&flagFormat, "format", formatDefault, + // TODO(cmd): Extract logic + if options.IsSarifEnabled() { + rootCmd.Flags().StringVar(&opts.PolicyFile, "policy", "", "policy to enforce") + rootCmd.Flags().StringVar(&opts.Format, "format", options.FormatDefault, "output format allowed values are [default, sarif, json]") } else { - rootCmd.Flags().StringVar(&flagFormat, "format", formatDefault, + rootCmd.Flags().StringVar(&opts.Format, "format", options.FormatDefault, "output format allowed values are [default, json]") } } @@ -108,28 +102,39 @@ func Execute() { } func scorecardCmd(cmd *cobra.Command, args []string) { - validateCmdFlags() + RunScorecard(args) +} + +// RunScorecard runs scorecard checks given a set of arguments. +// TODO(cmd): Is `args` required? +func RunScorecard(args []string) { + // TODO(cmd): Catch validation errors + valErrs := opts.Validate() + if len(valErrs) != 0 { + log.Panicf( + "the following validation errors occurred: %+v", + valErrs, + ) + } // Set `repo` from package managers. - pkgResp, err := fetchGitRepositoryFromPackageManagers(flagNPM, flagPyPI, flagRubyGems) + pkgResp, err := fetchGitRepositoryFromPackageManagers(opts.NPM, opts.PyPI, opts.RubyGems) if err != nil { log.Panic(err) } if pkgResp.exists { - if err := cmd.Flags().Set("repo", pkgResp.associatedRepo); err != nil { - log.Panic(err) - } + opts.Repo = pkgResp.associatedRepo } - policy, err := readPolicy() + pol, err := policy.ParseFromFile(opts.PolicyFile) if err != nil { log.Panicf("readPolicy: %v", err) } ctx := context.Background() - logger := sclog.NewLogger(sclog.ParseLevel(flagLogLevel)) - repoURI, repoClient, ossFuzzRepoClient, ciiClient, vulnsClient, err := getRepoAccessors( - ctx, flagRepo, flagLocal, logger) + logger := sclog.NewLogger(sclog.ParseLevel(opts.LogLevel)) + repoURI, repoClient, ossFuzzRepoClient, ciiClient, vulnsClient, err := checker.GetClients( + ctx, opts.Repo, opts.Local, logger) if err != nil { log.Panic(err) } @@ -145,279 +150,58 @@ func scorecardCmd(cmd *cobra.Command, args []string) { } var requiredRequestTypes []checker.RequestType - if flagLocal != "" { + if opts.Local != "" { requiredRequestTypes = append(requiredRequestTypes, checker.FileBased) } - if !strings.EqualFold(flagCommit, clients.HeadSHA) { + if !strings.EqualFold(opts.Commit, clients.HeadSHA) { requiredRequestTypes = append(requiredRequestTypes, checker.CommitBased) } - enabledChecks, err := getEnabledChecks(policy, flagChecksToRun, requiredRequestTypes) + enabledChecks, err := policy.GetEnabled(pol, opts.ChecksToRun, requiredRequestTypes) if err != nil { log.Panic(err) } - if flagFormat == formatDefault { + if opts.Format == options.FormatDefault { for checkName := range enabledChecks { fmt.Fprintf(os.Stderr, "Starting [%s]\n", checkName) } } - repoResult, err := pkg.RunScorecards(ctx, repoURI, flagCommit, flagFormat == formatRaw, enabledChecks, repoClient, - ossFuzzRepoClient, ciiClient, vulnsClient) + repoResult, err := pkg.RunScorecards( + ctx, + repoURI, + opts.Commit, + opts.Format == options.FormatRaw, + enabledChecks, + repoClient, + ossFuzzRepoClient, + ciiClient, + vulnsClient, + ) if err != nil { log.Panic(err) } - repoResult.Metadata = append(repoResult.Metadata, flagMetadata...) + repoResult.Metadata = append(repoResult.Metadata, opts.Metadata...) // Sort them by name sort.Slice(repoResult.Checks, func(i, j int) bool { return repoResult.Checks[i].Name < repoResult.Checks[j].Name }) - if flagFormat == formatDefault { + if opts.Format == options.FormatDefault { for checkName := range enabledChecks { fmt.Fprintf(os.Stderr, "Finished [%s]\n", checkName) } fmt.Println("\nRESULTS\n-------") } - switch flagFormat { - case formatDefault: - err = repoResult.AsString(flagShowDetails, sclog.ParseLevel(flagLogLevel), checkDocs, os.Stdout) - case formatSarif: - // TODO: support config files and update checker.MaxResultScore. - err = repoResult.AsSARIF(flagShowDetails, sclog.ParseLevel(flagLogLevel), os.Stdout, checkDocs, policy) - case formatJSON: - err = repoResult.AsJSON2(flagShowDetails, sclog.ParseLevel(flagLogLevel), checkDocs, os.Stdout) - case formatRaw: - err = repoResult.AsRawJSON(os.Stdout) - default: - err = sce.WithMessage(sce.ErrScorecardInternal, - fmt.Sprintf("invalid format flag: %v. Expected [default, json]", flagFormat)) - } - if err != nil { + resultsErr := pkg.FormatResults( + opts, + &repoResult, + checkDocs, + pol, + ) + if resultsErr != nil { log.Panicf("Failed to output results: %v", err) } } - -func validateCmdFlags() { - // Validate exactly one of `--repo`, `--npm`, `--pypi`, `--rubygems`, `--local` is enabled. - if boolSum(flagRepo != "", - flagNPM != "", - flagPyPI != "", - flagRubyGems != "", - flagLocal != "") != 1 { - log.Panic("Exactly one of `--repo`, `--npm`, `--pypi`, `--rubygems` or `--local` must be set") - } - - // Validate SARIF features are flag-guarded. - if !isSarifEnabled() { - if flagFormat == formatSarif { - log.Panic("sarif format not supported yet") - } - if flagPolicyFile != "" { - log.Panic("policy file not supported yet") - } - } - - // Validate V6 features are flag-guarded. - if !isV6Enabled() { - if flagFormat == formatRaw { - log.Panic("raw option not supported yet") - } - if flagCommit != clients.HeadSHA { - log.Panic("--commit option not supported yet") - } - } - - // Validate format. - if !validateFormat(flagFormat) { - log.Panicf("unsupported format '%s'", flagFormat) - } - - // Validate `commit` is non-empty. - if flagCommit == "" { - log.Panic("commit should be non-empty") - } -} - -func boolSum(bools ...bool) int { - sum := 0 - for _, b := range bools { - if b { - sum++ - } - } - return sum -} - -func isSarifEnabled() bool { - // UPGRADEv4: remove. - var sarifEnabled bool - _, sarifEnabled = os.LookupEnv(cliEnableSarif) - return sarifEnabled -} - -func isV6Enabled() bool { - var v6 bool - _, v6 = os.LookupEnv("SCORECARD_V6") - return v6 -} - -func validateFormat(format string) bool { - switch format { - case formatJSON, formatSarif, formatDefault, formatRaw: - return true - default: - return false - } -} - -func readPolicy() (*spol.ScorecardPolicy, error) { - if flagPolicyFile != "" { - data, err := os.ReadFile(flagPolicyFile) - if err != nil { - return nil, sce.WithMessage(sce.ErrScorecardInternal, - fmt.Sprintf("os.ReadFile: %v", err)) - } - sp, err := spol.ParseFromYAML(data) - if err != nil { - return nil, - sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("spol.ParseFromYAML: %v", err)) - } - return sp, nil - } - return nil, nil -} - -func checksHavePolicies(sp *spol.ScorecardPolicy, enabledChecks checker.CheckNameToFnMap) bool { - for checkName := range enabledChecks { - _, exists := sp.Policies[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.AllChecks[checkName].SupportedRequestTypes) - return len(unsupported) == 0 -} - -func getAllChecks() checker.CheckNameToFnMap { - // Returns the full list of checks, given any environment variable constraints. - possibleChecks := checks.AllChecks - return possibleChecks -} - -func getEnabledChecks(sp *spol.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 getAllChecks() { - 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 getRepoAccessors(ctx context.Context, repoURI, localURI string, logger *sclog.Logger) ( - clients.Repo, // repo - clients.RepoClient, // repoClient - clients.RepoClient, // ossFuzzClient - clients.CIIBestPracticesClient, // ciiClient - clients.VulnerabilitiesClient, // vulnClient - error) { - var githubRepo clients.Repo - var errGitHub error - if localURI != "" { - localRepo, errLocal := localdir.MakeLocalDirRepo(localURI) - return localRepo, /*repo*/ - localdir.CreateLocalDirClient(ctx, logger), /*repoClient*/ - nil, /*ossFuzzClient*/ - nil, /*ciiClient*/ - nil, /*vulnClient*/ - errLocal - } - - githubRepo, errGitHub = githubrepo.MakeGithubRepo(repoURI) - if errGitHub != nil { - // nolint: wrapcheck - return githubRepo, - nil, - nil, - nil, - nil, - errGitHub - } - - ossFuzzRepoClient, errOssFuzz := githubrepo.CreateOssFuzzRepoClient(ctx, logger) - return githubRepo, /*repo*/ - githubrepo.CreateGithubRepoClient(ctx, logger), /*repoClient*/ - ossFuzzRepoClient, /*ossFuzzClient*/ - clients.DefaultCIIBestPracticesClient(), /*ciiClient*/ - clients.DefaultVulnerabilitiesClient(), /*vulnClient*/ - errOssFuzz -} - -// Enables checks by name. -func enableCheck(checkName string, enabledChecks *checker.CheckNameToFnMap) bool { - if enabledChecks != nil { - for key, checkFn := range getAllChecks() { - if strings.EqualFold(key, checkName) { - (*enabledChecks)[key] = checkFn - return true - } - } - } - return false -} diff --git a/cmd/serve.go b/cmd/serve.go index de14e030..70366312 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -40,7 +40,7 @@ var serveCmd = &cobra.Command{ Short: "Serve the scorecard program over http", Long: ``, Run: func(cmd *cobra.Command, args []string) { - logger := log.NewLogger(log.ParseLevel(flagLogLevel)) + logger := log.NewLogger(log.ParseLevel(opts.LogLevel)) t, err := template.New("webpage").Parse(tpl) if err != nil { @@ -79,7 +79,7 @@ var serveCmd = &cobra.Command{ } if r.Header.Get("Content-Type") == "application/json" { - if err := repoResult.AsJSON(flagShowDetails, log.ParseLevel(flagLogLevel), rw); err != nil { + if err := repoResult.AsJSON(opts.ShowDetails, log.ParseLevel(opts.LogLevel), rw); err != nil { // TODO(log): Improve error message logger.Error(err, "") rw.WriteHeader(http.StatusInternalServerError) diff --git a/options/options.go b/options/options.go new file mode 100644 index 00000000..84846ec5 --- /dev/null +++ b/options/options.go @@ -0,0 +1,187 @@ +// Copyright 2020 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 options implements Scorecard options. +package options + +import ( + "errors" + "os" + + "github.com/ossf/scorecard/v4/clients" + "github.com/ossf/scorecard/v4/log" +) + +// Options define common options for configuring scorecard. +type Options struct { + Repo string + Local string + Commit string + LogLevel string + Format string + NPM string + PyPI string + RubyGems string + PolicyFile string + ChecksToRun []string + Metadata []string + ShowDetails bool +} + +// New creates a new instance of `Options`. +func New() *Options { + return &Options{} +} + +const ( + // DefaultCommit specifies the default commit reference to use. + DefaultCommit = clients.HeadSHA + + // Formats. + + // FormatJSON specifies that results should be output in JSON format. + FormatJSON = "json" + // FormatSarif specifies that results should be output in SARIF format. + FormatSarif = "sarif" + // FormatDefault specifies that results should be output in default format. + FormatDefault = "default" + // FormatRaw specifies that results should be output in raw format. + FormatRaw = "raw" + + // Environment variables. + + // EnvVarEnableSarif is the environment variable which controls enabling + // SARIF logging. + EnvVarEnableSarif = "ENABLE_SARIF" + // EnvVarScorecardV6 is the environment variable which enables scorecard v6 + // options. + EnvVarScorecardV6 = "SCORECARD_V6" +) + +var ( + // DefaultLogLevel retrieves the default log level. + DefaultLogLevel = log.DefaultLevel.String() + + errCommitIsEmpty = errors.New("commit should be non-empty") + errCommitOptionNotSupported = errors.New("commit option is not supported yet") + errFormatNotSupported = errors.New("unsupported format") + errPolicyFileNotSupported = errors.New("policy file is not supported yet") + errRawOptionNotSupported = errors.New("raw option is not supported yet") + errRepoOptionMustBeSet = errors.New( + "exactly one of `repo`, `npm`, `pypi`, `rubygems` or `local` must be set", + ) + errSARIFNotSupported = errors.New("SARIF format is not supported yet") +) + +// Validate validates scorecard configuration options. +// TODO(options): Cleanup error messages. +func (o *Options) Validate() []error { + var errs []error + + // Validate exactly one of `--repo`, `--npm`, `--pypi`, `--rubygems`, `--local` is enabled. + if boolSum(o.Repo != "", + o.NPM != "", + o.PyPI != "", + o.RubyGems != "", + o.Local != "") != 1 { + errs = append( + errs, + errRepoOptionMustBeSet, + ) + } + + // Validate SARIF features are flag-guarded. + if !IsSarifEnabled() { + if o.Format == FormatSarif { + errs = append( + errs, + errSARIFNotSupported, + ) + } + if o.PolicyFile != "" { + errs = append( + errs, + errPolicyFileNotSupported, + ) + } + } + + // Validate V6 features are flag-guarded. + if !isV6Enabled() { + if o.Format == FormatRaw { + errs = append( + errs, + errRawOptionNotSupported, + ) + } + if o.Commit != clients.HeadSHA { + errs = append( + errs, + errCommitOptionNotSupported, + ) + } + } + + // Validate format. + if !validateFormat(o.Format) { + errs = append( + errs, + errFormatNotSupported, + ) + } + + // Validate `commit` is non-empty. + if o.Commit == "" { + errs = append( + errs, + errCommitIsEmpty, + ) + } + + return errs +} + +func boolSum(bools ...bool) int { + sum := 0 + for _, b := range bools { + if b { + sum++ + } + } + return sum +} + +// IsSarifEnabled returns true if `EnvVarEnableSarif` is specified. +// TODO(options): This probably doesn't need to be exported. +func IsSarifEnabled() bool { + // UPGRADEv4: remove. + var sarifEnabled bool + _, sarifEnabled = os.LookupEnv(EnvVarEnableSarif) + return sarifEnabled +} + +func isV6Enabled() bool { + var v6 bool + _, v6 = os.LookupEnv(EnvVarScorecardV6) + return v6 +} + +func validateFormat(format string) bool { + switch format { + case FormatJSON, FormatSarif, FormatDefault, FormatRaw: + return true + default: + return false + } +} diff --git a/pkg/scorecard.go b/pkg/scorecard.go index 42e2decb..cfda7297 100644 --- a/pkg/scorecard.go +++ b/pkg/scorecard.go @@ -48,11 +48,12 @@ func runEnabledChecks(ctx context.Context, wg.Add(1) go func() { defer wg.Done() - runner := checker.Runner{ - Repo: repo.URI(), - CheckName: checkName, - CheckRequest: request, - } + runner := checker.NewRunner( + checkName, + repo.URI(), + &request, + ) + resultsCh <- runner.Run(ctx, checkFn) }() } diff --git a/pkg/scorecard_result.go b/pkg/scorecard_result.go index 6c315065..efc388c2 100644 --- a/pkg/scorecard_result.go +++ b/pkg/scorecard_result.go @@ -23,9 +23,11 @@ import ( "github.com/olekukonko/tablewriter" "github.com/ossf/scorecard/v4/checker" - docs "github.com/ossf/scorecard/v4/docs/checks" + "github.com/ossf/scorecard/v4/docs/checks" sce "github.com/ossf/scorecard/v4/errors" "github.com/ossf/scorecard/v4/log" + "github.com/ossf/scorecard/v4/options" + spol "github.com/ossf/scorecard/v4/policy" ) // ScorecardInfo contains information about the scorecard code that was run. @@ -58,7 +60,7 @@ func scoreToString(s float64) string { } // GetAggregateScore returns the aggregate score. -func (r *ScorecardResult) GetAggregateScore(checkDocs docs.Doc) (float64, error) { +func (r *ScorecardResult) GetAggregateScore(checkDocs checks.Doc) (float64, error) { // TODO: calculate the score and make it a field // of ScorecardResult weights := map[string]float64{"Critical": 10, "High": 7.5, "Medium": 5, "Low": 2.5} @@ -97,9 +99,45 @@ func (r *ScorecardResult) GetAggregateScore(checkDocs docs.Doc) (float64, error) return score / total, nil } +// FormatResults formats scorecard results. +func FormatResults( + opts *options.Options, + results *ScorecardResult, + doc checks.Doc, + policy *spol.ScorecardPolicy, +) error { + var err error + + switch opts.Format { + case options.FormatDefault: + err = results.AsString(opts.ShowDetails, log.ParseLevel(opts.LogLevel), doc, os.Stdout) + case options.FormatSarif: + // TODO: support config files and update checker.MaxResultScore. + err = results.AsSARIF(opts.ShowDetails, log.ParseLevel(opts.LogLevel), os.Stdout, doc, policy) + case options.FormatJSON: + err = results.AsJSON2(opts.ShowDetails, log.ParseLevel(opts.LogLevel), doc, os.Stdout) + case options.FormatRaw: + err = results.AsRawJSON(os.Stdout) + default: + err = sce.WithMessage( + sce.ErrScorecardInternal, + fmt.Sprintf( + "invalid format flag: %v. Expected [default, json]", + opts.Format, + ), + ) + } + + if err != nil { + return fmt.Errorf("failed to output results: %w", err) + } + + return nil +} + // AsString returns ScorecardResult in string format. func (r *ScorecardResult) AsString(showDetails bool, logLevel log.Level, - checkDocs docs.Doc, writer io.Writer) error { + checkDocs checks.Doc, writer io.Writer) error { data := make([][]string, len(r.Checks)) //nolint for i, row := range r.Checks { diff --git a/policy/policy.go b/policy/policy.go index df7db99a..817b387c 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -17,9 +17,13 @@ package policy import ( "errors" "fmt" + "log" + "os" + "strings" "gopkg.in/yaml.v3" + "github.com/ossf/scorecard/v4/checker" "github.com/ossf/scorecard/v4/checks" sce "github.com/ossf/scorecard/v4/errors" ) @@ -62,9 +66,29 @@ func modeToProto(m string) CheckPolicy_Mode { } } -// ParseFromYAML parses a policy file and returns -// a scorecardPolicy. -func ParseFromYAML(b []byte) (*ScorecardPolicy, error) { +// 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). @@ -112,3 +136,94 @@ func ParseFromYAML(b []byte) (*ScorecardPolicy, error) { 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.Policies[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.AllChecks[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.GetAll() { + if strings.EqualFold(key, checkName) { + (*enabledChecks)[key] = checkFn + return true + } + } + } + return false +} diff --git a/policy/policy_test.go b/policy/policy_test.go index 14dcfbef..306519b5 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -114,7 +114,7 @@ func TestPolicyRead(t *testing.T) { t.Fatalf("cannot read file: %v", err) } - p, err := ParseFromYAML(content) + p, err := parseFromYAML(content) if !errors.Is(err, tt.err) { t.Fatalf("%s: expected %v, got %v", tt.name, tt.err, err)