diff --git a/cmd/internal/org/org.go b/cmd/internal/org/org.go new file mode 100644 index 00000000000..75e54ca4143 --- /dev/null +++ b/cmd/internal/org/org.go @@ -0,0 +1,127 @@ +// 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 org + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/go-github/v53/github" + + "github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper" + "github.com/ossf/scorecard/v5/log" +) + +// ErrNilResponse indicates the GitHub API returned a nil response object. +var ErrNilResponse = errors.New("nil response from GitHub API") + +// ListOrgRepos lists all non-archived repositories for a GitHub organization. +// The caller should provide an http.RoundTripper (rt). If rt is nil, the +// default transport will be created via roundtripper.NewTransport. +func ListOrgRepos(ctx context.Context, orgName string, rt http.RoundTripper) ([]string, error) { + // Parse org name if needed. + if len(orgName) > 0 { + if parsed := parseOrgName(orgName); parsed != "" { + orgName = parsed + } + } + + // Use the centralized transport so we respect token rotation, GitHub App + // auth, rate limiting and instrumentation already implemented in + // clients/githubrepo/roundtripper. + logger := log.NewLogger(log.DefaultLevel) + if rt == nil { + rt = roundtripper.NewTransport(ctx, logger) + } + httpClient := &http.Client{Transport: rt} + client := github.NewClient(httpClient) + + opt := &github.RepositoryListByOrgOptions{ + Type: "all", + } + + var urls []string + for { + repos, resp, err := client.Repositories.ListByOrg(ctx, orgName, opt) + if err != nil { + return nil, fmt.Errorf("failed to list repos: %w", err) + } + + for _, r := range repos { + if r.GetArchived() { + continue + } + urls = append(urls, r.GetHTMLURL()) + } + + if resp == nil { + return nil, ErrNilResponse + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return urls, nil +} + +// parseOrgName extracts the GitHub organization from a supported input. +// Supported: +// - owner > owner +// - github.com/owner > owner +// - http://github.com/owner > owner +// - https://github.com/owner > owner +// +// Returns "" if no org can be parsed. +func parseOrgName(input string) string { + s := strings.TrimSpace(input) + if s == "" { + return "" + } + + // Strip optional scheme. + switch { + case strings.HasPrefix(s, "https://"): + s = strings.TrimPrefix(s, "https://") + case strings.HasPrefix(s, "http://"): + s = strings.TrimPrefix(s, "http://") + } + + // If it's exactly the host, there's no org. + if s == "github.com" { + return "" + } + + // Strip host prefix if present. + if after, ok := strings.CutPrefix(s, "github.com/"); ok { + s = after + } + + // Keep only the first path segment (the org). + if i := strings.IndexByte(s, '/'); i >= 0 { + s = s[:i] + } + + // Basic sanity: org shouldn't contain dots (to avoid host-like values). + if s == "" || strings.Contains(s, ".") { + return "" + } + + return s +} diff --git a/cmd/internal/org/org_test.go b/cmd/internal/org/org_test.go new file mode 100644 index 00000000000..96f4615b664 --- /dev/null +++ b/cmd/internal/org/org_test.go @@ -0,0 +1,91 @@ +// 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 org + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseOrgName(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want string + }{ + {"http://github.com/owner", "owner"}, + {"https://github.com/owner", "owner"}, + {"github.com/owner", "owner"}, + {"owner", "owner"}, + {"", ""}, + } + for _, c := range cases { + if got := parseOrgName(c.in); got != c.want { + t.Fatalf("parseOrgName(%q) = %q; want %q", c.in, got, c.want) + } + } +} + +// Test ListOrgRepos handles pagination and filters archived repos. +func TestListOrgRepos_PaginationAndArchived(t *testing.T) { + t.Parallel() + // Single page: one archived repo and two active repos; expect active ones returned. + body := `[ + {"html_url": "https://github.com/owner/repo1", "archived": true}, + {"html_url": "https://github.com/owner/repo2", "archived": false}, + {"html_url": "https://github.com/owner/repo3", "archived": false} + ]` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(body)); err != nil { + t.Fatalf("failed to write response: %v", err) + } + })) + defer srv.Close() + + // Override TransportFactory to redirect requests to our test server. + rt := roundTripperToServer(srv.URL) + + repos, err := ListOrgRepos(context.Background(), "owner", rt) + if err != nil { + t.Fatalf("ListOrgRepos returned error: %v", err) + } + // Expect repo2 and repo3 (repo1 archived) + if len(repos) != 2 { + t.Fatalf("expected 2 repos, got %d: %v", len(repos), repos) + } + if !strings.Contains(repos[0], "repo2") || !strings.Contains(repos[1], "repo3") { + t.Fatalf("unexpected repos: %v", repos) + } +} + +// roundTripperToServer returns an http.RoundTripper that rewrites requests +// to the given serverURL, keeping the path and query intact. +func roundTripperToServer(serverURL string) http.RoundTripper { + return http.RoundTripper(httpTransportFunc(func(req *http.Request) (*http.Response, error) { + // rewrite target + req.URL.Scheme = "http" + req.URL.Host = strings.TrimPrefix(serverURL, "http://") + return http.DefaultTransport.RoundTrip(req) + })) +} + +// httpTransportFunc converts a function into an http.RoundTripper. +type httpTransportFunc func(*http.Request) (*http.Response, error) + +func (f httpTransportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/cmd/root.go b/cmd/root.go index 23299e03edb..6a9d8375951 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ // limitations under the License. // Package cmd implements Scorecard command-line. + package cmd import ( @@ -32,19 +33,23 @@ import ( "github.com/ossf/scorecard/v5/clients/githubrepo" "github.com/ossf/scorecard/v5/clients/gitlabrepo" "github.com/ossf/scorecard/v5/clients/localdir" + orgpkg "github.com/ossf/scorecard/v5/cmd/internal/org" pmc "github.com/ossf/scorecard/v5/cmd/internal/packagemanager" docs "github.com/ossf/scorecard/v5/docs/checks" - sce "github.com/ossf/scorecard/v5/errors" sclog "github.com/ossf/scorecard/v5/log" "github.com/ossf/scorecard/v5/options" "github.com/ossf/scorecard/v5/pkg/scorecard" "github.com/ossf/scorecard/v5/policy" ) +// errChecksFailed is returned when one or more checks produced a runtime +// error during execution. +var errChecksFailed = errors.New("one or more checks failed during execution") + const ( scorecardLong = "A program that shows the OpenSSF scorecard for an open source software." - scorecardUse = `./scorecard (--repo= | --local= | --{npm,pypi,rubygems,nuget}=) - [--checks=check1,...] [--show-details] [--show-annotations]` + scorecardUse = `./scorecard (--repo= | --local= | --org= | ` + + `--{npm,pypi,rubygems,nuget}=) [--checks=check1,...] [--show-details] [--show-annotations]` scorecardShort = "OpenSSF Scorecard" ) @@ -61,6 +66,7 @@ func New(o *options.Options) *cobra.Command { } // options are good at this point. silence usage so it doesn't print for runtime errors cmd.SilenceUsage = true + return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -76,39 +82,62 @@ func New(o *options.Options) *cobra.Command { return cmd } -// rootCmd runs scorecard checks given a set of arguments. -func rootCmd(o *options.Options) error { - var err error - var repoResult scorecard.Result +// Build the list of repositories to scan. +func buildRepoURLs(ctx context.Context, o *options.Options) ([]string, error) { + // --repos has highest precedence + if len(o.Repos) > 0 { + var urls []string + for _, r := range o.Repos { + r = strings.TrimSpace(r) + if r != "" { + urls = append(urls, r) + } + } + return urls, nil + } + // --org: expand to all non-archived repos + if o.Org != "" { + repos, err := orgpkg.ListOrgRepos(ctx, o.Org, nil) + if err != nil { + return nil, fmt.Errorf("listing repositories for org %q: %w", o.Org, err) + } + return repos, nil + } + + // --local: single local path + if o.Local != "" { + return []string{o.Local}, nil + } + + // Package managers may override --repo p := &pmc.PackageManagerClient{} // Set `repo` from package managers. pkgResp, err := fetchGitRepositoryFromPackageManagers(o.NPM, o.PyPI, o.RubyGems, o.Nuget, p) if err != nil { - return fmt.Errorf("fetchGitRepositoryFromPackageManagers: %w", err) + return nil, fmt.Errorf("fetchGitRepositoryFromPackageManagers: %w", err) } if pkgResp.exists { o.Repo = pkgResp.associatedRepo } - pol, err := policy.ParseFromFile(o.PolicyFile) - if err != nil { - return fmt.Errorf("readPolicy: %w", err) - } + return []string{o.Repo}, nil +} +// rootCmd runs scorecard checks given a set of arguments. +func rootCmd(o *options.Options) error { ctx := context.Background() - var repo clients.Repo - if o.Local != "" { - repo, err = localdir.MakeLocalDirRepo(o.Local) - if err != nil { - return fmt.Errorf("making local dir: %w", err) - } - } else { - repo, err = makeRepo(o.Repo) - if err != nil { - return fmt.Errorf("making remote repo: %w", err) - } + // Build the list of repos (only split this logic out) + repoURLs, err := buildRepoURLs(ctx, o) + if err != nil { + return err + } + + // Shared setup + pol, err := policy.ParseFromFile(o.PolicyFile) + if err != nil { + return fmt.Errorf("readPolicy: %w", err) } // Read docs. @@ -126,6 +155,7 @@ func rootCmd(o *options.Options) error { if !strings.EqualFold(o.Commit, clients.HeadSHA) { requiredRequestTypes = append(requiredRequestTypes, checker.CommitBased) } + // this call to policy is different from the one in scorecard.Run // this one is concerned with a policy file, while the scorecard.Run call is // more concerned with the supported request types @@ -139,13 +169,6 @@ func rootCmd(o *options.Options) error { } enabledProbes := o.Probes() - if o.Format == options.FormatDefault { - if len(enabledProbes) > 0 { - printProbeStart(enabledProbes) - } else { - printCheckStart(enabledChecks) - } - } opts := []scorecard.Option{ scorecard.WithLogLevel(sclog.ParseLevel(o.LogLevel)), @@ -158,68 +181,58 @@ func rootCmd(o *options.Options) error { opts = append(opts, scorecard.WithFileModeGit()) } - repoResult, err = scorecard.Run(ctx, repo, opts...) - if err != nil { - return fmt.Errorf("scorecard.Run: %w", err) - } - - repoResult.Metadata = append(repoResult.Metadata, o.Metadata...) - - // Sort them by name - sort.Slice(repoResult.Checks, func(i, j int) bool { - return repoResult.Checks[i].Name < repoResult.Checks[j].Name - }) + // Track whether any check produced a runtime error during scans. We want to + // continue scanning all repos but return a non-nil error at the end so the + // process exit code reflects that something went wrong. + var sawRuntimeErr bool + // Iterate and scan each repo using a helper to keep rootCmd small. + for _, uri := range repoURLs { + res, err := processRepo(ctx, uri, o, enabledProbes, enabledChecks, opts, checkDocs, pol) + if err != nil { + // processRepo already logged details; skip this URI. + fmt.Fprintf(os.Stderr, "Skipping %s: %v\n", uri, err) + continue + } - if o.Format == options.FormatDefault { - if len(enabledProbes) > 0 { - printProbeResults(enabledProbes) - } else { - printCheckResults(enabledChecks) + // If any checks had runtime errors, remember that fact so we can return + // a non-zero exit code after processing all repos. + for _, c := range res.Checks { + if c.Error != nil { + sawRuntimeErr = true + break + } } } - resultsErr := scorecard.FormatResults( - o, - &repoResult, - checkDocs, - pol, - ) - if resultsErr != nil { - return fmt.Errorf("failed to format results: %w", resultsErr) + if sawRuntimeErr { + return errChecksFailed } - // intentionally placed at end to preserve outputting results, even if a check has a runtime error - for _, result := range repoResult.Checks { - if result.Error != nil { - return sce.WithMessage(sce.ErrCheckRuntime, fmt.Sprintf("%s: %v", result.Name, result.Error)) - } - } return nil } -func printProbeStart(enabledProbes []string) { +func printProbeStart(repo string, enabledProbes []string) { for _, probeName := range enabledProbes { - fmt.Fprintf(os.Stderr, "Starting probe [%s]\n", probeName) + fmt.Fprintf(os.Stderr, "Starting (%s) probe [%s]\n", repo, probeName) } } -func printCheckStart(enabledChecks checker.CheckNameToFnMap) { +func printCheckStart(repo string, enabledChecks checker.CheckNameToFnMap) { for checkName := range enabledChecks { - fmt.Fprintf(os.Stderr, "Starting [%s]\n", checkName) + fmt.Fprintf(os.Stderr, "Starting (%s) [%s]\n", repo, checkName) } } -func printProbeResults(enabledProbes []string) { +func printProbeResults(repo string, enabledProbes []string) { for _, probeName := range enabledProbes { - fmt.Fprintf(os.Stderr, "Finished probe %s\n", probeName) + fmt.Fprintf(os.Stderr, "Finished (%s) probe %s\n", repo, probeName) } } -func printCheckResults(enabledChecks checker.CheckNameToFnMap) { +func printCheckResults(repo string, enabledChecks checker.CheckNameToFnMap) { for checkName := range enabledChecks { - fmt.Fprintf(os.Stderr, "Finished [%s]\n", checkName) + fmt.Fprintf(os.Stderr, "Finished (%s) [%s]\n", repo, checkName) } - fmt.Fprintln(os.Stderr, "\nRESULTS\n-------") } // makeRepo helps turn a URI into the appropriate clients.Repo. @@ -253,3 +266,76 @@ func makeRepo(uri string) (clients.Repo, error) { return nil, fmt.Errorf("unable to parse as github, gitlab, or azuredevops: %w", compositeErr) } + +// processRepo performs the scanning and formatting for a single repo URI. +// It returns the Result when successful or an error describing why the URI +// should be skipped. +func processRepo( + ctx context.Context, + uri string, + o *options.Options, + enabledProbes []string, + enabledChecks checker.CheckNameToFnMap, + opts []scorecard.Option, + checkDocs docs.Doc, + pol *policy.ScorecardPolicy, +) (*scorecard.Result, error) { + var repo clients.Repo + var err error + + if o.Local != "" && uri == o.Local { + repo, err = localdir.MakeLocalDirRepo(uri) + if err != nil { + return nil, fmt.Errorf("localdir: %w", err) + } + } else { + repo, err = makeRepo(uri) + if err != nil { + return nil, err + } + } + + // Start banners with repo uri (show banners in default format only) + if o.Format == options.FormatDefault { + if len(enabledProbes) > 0 { + printProbeStart(uri, enabledProbes) + } else { + printCheckStart(uri, enabledChecks) + } + } + + result, err := scorecard.Run(ctx, repo, opts...) + if err != nil { + return nil, fmt.Errorf("run: %w", err) + } + + result.Metadata = append(result.Metadata, o.Metadata...) + + // Stable order + sort.Slice(result.Checks, func(i, j int) bool { + return result.Checks[i].Name < result.Checks[j].Name + }) + + // End banners BEFORE RESULTS + if o.Format == options.FormatDefault { + if len(enabledProbes) > 0 { + printProbeResults(uri, enabledProbes) + } else { + printCheckResults(uri, enabledChecks) + fmt.Fprintln(os.Stderr, "\nRESULTS\n-------") + } + } + + if err := scorecard.FormatResults(o, &result, checkDocs, pol); err != nil { + fmt.Fprintf(os.Stderr, "Failed to format results for %s: %v\n", uri, err) + } + + // Surface per-check runtime errors (non-fatal) + for _, r := range result.Checks { + if r.Error != nil { + fmt.Fprintf(os.Stderr, "Check %s failed for %s: %v\n", r.Name, uri, r.Error) + } + } + + return &result, nil +} diff --git a/options/flags.go b/options/flags.go index eca4301ad28..127ffdb6f72 100644 --- a/options/flags.go +++ b/options/flags.go @@ -25,7 +25,9 @@ import ( const ( // FlagRepo is the flag name for specifying a repository. - FlagRepo = "repo" + FlagRepo = "repo" + FlagRepos = "repos" + FlagOrg = "org" // FlagLocal is the flag name for specifying a local run. FlagLocal = "local" @@ -95,6 +97,22 @@ func (o *Options) AddFlags(cmd *cobra.Command) { "repository to check (valid inputs: \"owner/repo\", \"github.com/owner/repo\", \"https://github.com/repo\")", ) + cmd.Flags().StringSliceVar( + &o.Repos, + FlagRepos, + o.Repos, + "repositories to check (,, ...). Each repo must be one of:"+ + " \"owner/repo\", \"github.com/owner/repo\", \"https://github.com/repo\"", + ) + + cmd.Flags().StringVar( + &o.Org, + FlagOrg, + o.Org, + "scans all non-archived repositories in an organization. "+ + "Currently only supports GitHub, e.g., 'github.com/ossf' or 'ossf'", + ) + cmd.Flags().StringVar( &o.Local, FlagLocal, diff --git a/options/options.go b/options/options.go index 128a034d063..6661b3efa8a 100644 --- a/options/options.go +++ b/options/options.go @@ -31,6 +31,8 @@ import ( // Options define common options for configuring scorecard. type Options struct { Repo string + Repos []string + Org string Local string Commit string LogLevel string @@ -83,7 +85,7 @@ const ( FormatDefault = "default" // FormatRaw specifies that results should be output in raw format. FormatRaw = "raw" - // FormatInToyo specifies that results should be output in an in-toto statement. + // FormatInToto specifies that results should be output in an in-toto statement. FormatInToto = "intoto" // File Modes @@ -114,7 +116,7 @@ var ( 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`, `nuget` or `local` must be set", + "exactly one of `repo`, `repos`, `org`, `npm`, `pypi`, `rubygems`, `nuget` or `local` must be set", ) errSARIFNotSupported = errors.New("SARIF format is not supported yet") errValidate = errors.New("some options could not be validated") @@ -125,8 +127,11 @@ var ( func (o *Options) Validate() error { var errs []error - // Validate exactly one of `--repo`, `--npm`, `--pypi`, `--rubygems`, `--nuget`, `--local` is enabled. + // Validate exactly one of `--repo`, `--repos`, `--org`, `--npm`, `--pypi`, `--rubygems`, + // `--nuget`, `--local` is enabled. if boolSum(o.Repo != "", + len(o.Repos) > 0, + o.Org != "", o.NPM != "", o.PyPI != "", o.RubyGems != "",