diff --git a/cmd/internal/org/org.go b/cmd/internal/org/org.go new file mode 100644 index 00000000000..f41c08c58a5 --- /dev/null +++ b/cmd/internal/org/org.go @@ -0,0 +1,129 @@ +// 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 or leading "//". + switch { + case strings.HasPrefix(s, "https://"): + s = strings.TrimPrefix(s, "https://") + case strings.HasPrefix(s, "http://"): + s = strings.TrimPrefix(s, "http://") + case strings.HasPrefix(s, "//"): + s = strings.TrimPrefix(s, "//") + } + + // 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..350d854a9a0 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 ( @@ -30,11 +31,12 @@ import ( "github.com/ossf/scorecard/v5/clients" "github.com/ossf/scorecard/v5/clients/azuredevopsrepo" "github.com/ossf/scorecard/v5/clients/githubrepo" + "github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper" "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" @@ -43,8 +45,8 @@ import ( 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 +63,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 +79,65 @@ 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, honoring --repos > --org > --local > --repo/pkg-managers. +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 != "" { + // create a transport to respect auth, rate limiting, etc. + logger := sclog.NewLogger(sclog.DefaultLevel) + rt := roundtripper.NewTransport(ctx, logger) + repos, err := orgpkg.ListOrgRepos(ctx, o.Org, rt) + 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,91 @@ 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) + var allResults []*scorecard.Result + // Iterate and scan each repo using a helper to keep rootCmd small. + for _, uri := range repoURLs { + res, err := processRepo(ctx, uri, o, enabledProbes, enabledChecks, checks, 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.CombinedOutput && res != nil { + allResults = append(allResults, res) + } } - repoResult.Metadata = append(repoResult.Metadata, o.Metadata...) + // If combined output requested, render one combined table appended after + // all per-repo outputs. + if o.CombinedOutput && len(allResults) > 0 { + fmt.Fprintln(os.Stdout, "\nCOMBINED RESULTS\n----------------") + if err := scorecard.FormatCombinedResultsAll(os.Stdout, o, allResults, checkDocs, pol); err != nil { + fmt.Fprintf(os.Stderr, "Failed to format combined results: %v\n", err) + } + } - // Sort them by name - sort.Slice(repoResult.Checks, func(i, j int) bool { - return repoResult.Checks[i].Name < repoResult.Checks[j].Name - }) + return nil +} - if o.Format == options.FormatDefault { - if len(enabledProbes) > 0 { - printProbeResults(enabledProbes) - } else { - printCheckResults(enabledChecks) - } +// repoLabelFromURI returns "owner/repo" for supported inputs only. +// Supported formats: +// - owner/repo +// - github.com/owner/repo +// - https://github.com/owner/repo (http also accepted) +// - gitlab.com/owner/repo +// - https://gitlab.com/owner/repo (http also accepted) +func repoLabelFromURI(uri string) string { + s := strings.TrimSpace(uri) + if s == "" { + return uri } - resultsErr := scorecard.FormatResults( - o, - &repoResult, - checkDocs, - pol, - ) - if resultsErr != nil { - return fmt.Errorf("failed to format results: %w", resultsErr) + // Strip optional scheme. + if strings.HasPrefix(s, "https://") { + s = strings.TrimPrefix(s, "https://") + } else if strings.HasPrefix(s, "http://") { + s = strings.TrimPrefix(s, "http://") } - // 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)) - } + // Strip optional host. + if strings.HasPrefix(s, "github.com/") { + s = strings.TrimPrefix(s, "github.com/") + } else if strings.HasPrefix(s, "gitlab.com/") { + s = strings.TrimPrefix(s, "gitlab.com/") } - return nil + + // Expect owner/repo (ignore any extra path segments). + parts := strings.Split(s, "/") + if len(parts) >= 2 && parts[0] != "" && parts[1] != "" && !strings.Contains(parts[0], ".") { + return parts[0] + "/" + parts[1] + } + + // Not a supported format; return as-is. + return uri } -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 +299,85 @@ 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 when combined output is requested), +// 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, + checksList []string, + 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 + } + } + + label := repoLabelFromURI(uri) + + // Start banners with repo label (always show banners even in combined-only mode) + if o.Format == options.FormatDefault { + if len(enabledProbes) > 0 { + printProbeStart(label, enabledProbes) + } else { + printCheckStart(label, 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 (always show banners even in combined-only mode) + if o.Format == options.FormatDefault { + if len(enabledProbes) > 0 { + printProbeResults(label, enabledProbes) + } else { + printCheckResults(label, enabledChecks) + // Only print the RESULTS header when not in combined-only mode. + if !o.CombinedOutput { + fmt.Fprintln(os.Stderr, "\nRESULTS\n-------") + } + } + } + + // RESULTS block: render per-repo result only when not in combined-only mode. + if !o.CombinedOutput { + 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..14c1a922be2 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" @@ -78,6 +80,8 @@ const ( FlagCommitDepth = "commit-depth" FlagProbes = "probes" + + FlagCombinedOutput = "combined" ) // Command is an interface for handling options for command-line utilities. @@ -95,6 +99,21 @@ 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, + "GitHub organization to check (all non-archived repos will be checked), e.g., 'github.com/ossf' or 'ossf'", + ) + cmd.Flags().StringVar( &o.Local, FlagLocal, @@ -165,6 +184,13 @@ func (o *Options) AddFlags(cmd *cobra.Command) { "show maintainers annotations for checks", ) + cmd.Flags().BoolVar( + &o.CombinedOutput, + FlagCombinedOutput, + o.CombinedOutput, + "when scanning multiple repos, output a single combined table", + ) + cmd.Flags().IntVar( &o.CommitDepth, FlagCommitDepth, diff --git a/options/options.go b/options/options.go index 407f304f4eb..b9f4bf108a7 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 @@ -48,6 +50,7 @@ type Options struct { CommitDepth int ShowDetails bool ShowAnnotations bool + CombinedOutput bool // Feature flags. EnableSarif bool `env:"ENABLE_SARIF"` EnableScorecardV6 bool `env:"SCORECARD_V6"` @@ -114,7 +117,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 +128,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 != "", diff --git a/pkg/scorecard/combined_results_test.go b/pkg/scorecard/combined_results_test.go new file mode 100644 index 00000000000..c23df33e38d --- /dev/null +++ b/pkg/scorecard/combined_results_test.go @@ -0,0 +1,77 @@ +// 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 scorecard + +import ( + "bytes" + "strings" + "testing" + + "github.com/ossf/scorecard/v5/checker" + docChecks "github.com/ossf/scorecard/v5/docs/checks" + "github.com/ossf/scorecard/v5/options" +) + +// fakeDoc implements minimal docChecks.Doc for tests. +type fakeDoc struct{} + +func (f *fakeDoc) GetCheck(name string) (docChecks.CheckDoc, error) { + // Return a dummy check where GetRisk returns "Medium". + return &fakeCheck{}, nil +} + +func (f *fakeDoc) CheckExists(name string) bool { + return true +} + +func (f *fakeDoc) GetChecks() []docChecks.CheckDoc { + return []docChecks.CheckDoc{&fakeCheck{}} +} + +type fakeCheck struct{} + +func (f *fakeCheck) GetName() string { return "fake" } +func (f *fakeCheck) GetRisk() string { return "Medium" } +func (f *fakeCheck) GetShort() string { return "" } +func (f *fakeCheck) GetDescription() string { return "" } +func (f *fakeCheck) GetRemediation() []string { return nil } +func (f *fakeCheck) GetTags() []string { return nil } +func (f *fakeCheck) GetSupportedRepoTypes() []string { return nil } +func (f *fakeCheck) GetDocumentationURL(commitish string) string { return "http://example" } + +func TestFormatCombinedResultsAllAggregatedScore(t *testing.T) { + t.Parallel() + // Build two results with simple checks. + r1 := &Result{Repo: RepoInfo{Name: "owner/repo1"}} + r1.Checks = []checker.CheckResult{{Name: "c1", Score: 10}, {Name: "c2", Score: 5}} + + r2 := &Result{Repo: RepoInfo{Name: "owner/repo2"}} + r2.Checks = []checker.CheckResult{{Name: "c1", Score: 7}, {Name: "c2", Score: 7}} + + var buf bytes.Buffer + err := FormatCombinedResultsAll(&buf, &options.Options{ShowDetails: false, ShowAnnotations: false, LogLevel: "info"}, []*Result{r1, r2}, &fakeDoc{}, nil) + if err != nil { + t.Fatalf("FormatCombinedResultsAll failed: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "AGGREGATED SCORE") { + t.Fatalf("expected AGGREGATED SCORE header, got: %s", out) + } + // Expect repo names present. + if !strings.Contains(out, "owner/repo1") || !strings.Contains(out, "owner/repo2") { + t.Fatalf("expected repo names in output, got: %s", out) + } +} diff --git a/pkg/scorecard/scorecard_result.go b/pkg/scorecard/scorecard_result.go index 31db6791417..d8d94fb1a5d 100644 --- a/pkg/scorecard/scorecard_result.go +++ b/pkg/scorecard/scorecard_result.go @@ -145,6 +145,9 @@ func FormatResults( Annotations: opts.ShowAnnotations, LogLevel: log.ParseLevel(opts.LogLevel), } + // Always render the per-repo default string output here. Combined output + // (single table across multiple repos) is handled by the caller when + // scanning multiple repositories so we keep the original behavior. err = results.AsString(output, doc, o) case options.FormatSarif: // TODO: support config files and update checker.MaxResultScore. @@ -263,6 +266,157 @@ func (r *Result) AsString(writer io.Writer, checkDocs docChecks.Doc, opt *AsStri return nil } +// FormatCombinedResults prints a combined table with extra REPO and AGGREGATED SCORE columns. +// This expects `results` to contain checks for a single repo; callers scanning +// multiple repos should aggregate calls or invoke this helper appropriately. +func FormatCombinedResults( + writer io.Writer, + results *Result, + checkDocs docChecks.Doc, + opt *AsStringResultOption, + opts *options.Options, +) error { + // Build data rows where each check row is prefixed with the repo name. + if opt == nil { + opt = &AsStringResultOption{LogLevel: log.DefaultLevel} + } + + // Compute aggregated score for this repo (to show the same value on every row). + aggScore, aggErr := results.GetAggregateScore(checkDocs) + var aggStr string + if aggErr != nil { + aggStr = "?" + } else { + aggStr = fmt.Sprintf("%s / %d", scoreToString(aggScore), checker.MaxResultScore) + } + + data := make([][]string, len(results.Checks)) + for i, row := range results.Checks { + var x []string + x = append(x, results.Repo.Name, aggStr) + if row.Score == checker.InconclusiveResultScore { + x = append(x, "?") + } else { + x = append(x, fmt.Sprintf("%d / %d", row.Score, checker.MaxResultScore)) + } + cdoc, e := checkDocs.GetCheck(row.Name) + if e != nil { + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", row.Name, e)) + } + doc := cdoc.GetDocumentationURL(results.Scorecard.CommitSHA) + x = append(x, row.Name, row.Reason) + if opt.Details { + details, show := detailsToString(row.Details, opt.LogLevel) + if show { + x = append(x, details) + } + } + x = append(x, doc) + if opt.Annotations { + reasons := row.Annotations(results.Config) + x = append(x, strings.Join(reasons, "\n")) + } + data[i] = x + } + + table := tablewriter.NewWriter(writer) + header := []string{"REPO", "AGGREGATED SCORE", "Score", "Name", "Reason"} + if opt.Details { + header = append(header, "Details") + } + header = append(header, "Documentation/Remediation") + if opt.Annotations { + header = append(header, "Annotation") + } + table.SetHeader(header) + table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true}) + table.SetRowSeparator("-") + table.SetRowLine(true) + table.SetCenterSeparator("|") + table.AppendBulk(data) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetRowLine(true) + table.Render() + return nil +} + +// FormatCombinedResultsAll renders a single table containing rows from multiple +// repo results. Each check from each repo becomes a row prefixed with the repo. +func FormatCombinedResultsAll( + writer io.Writer, + opts *options.Options, + allResults []*Result, + checkDocs docChecks.Doc, + policy *spol.ScorecardPolicy, +) error { + o := &AsStringResultOption{ + Details: opts.ShowDetails, + Annotations: opts.ShowAnnotations, + LogLevel: log.ParseLevel(opts.LogLevel), + } + + // Aggregate rows across all results. + var data [][]string + for _, res := range allResults { + // Compute aggregated score for this repo once. + aggScore, aggErr := res.GetAggregateScore(checkDocs) + var aggStr string + if aggErr != nil { + aggStr = "?" + } else { + aggStr = fmt.Sprintf("%s / %d", scoreToString(aggScore), checker.MaxResultScore) + } + for _, row := range res.Checks { + var x []string + x = append(x, res.Repo.Name, aggStr) + if row.Score == checker.InconclusiveResultScore { + x = append(x, "?") + } else { + x = append(x, fmt.Sprintf("%d / %d", row.Score, checker.MaxResultScore)) + } + cdoc, e := checkDocs.GetCheck(row.Name) + if e != nil { + return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", row.Name, e)) + } + doc := cdoc.GetDocumentationURL(res.Scorecard.CommitSHA) + x = append(x, row.Name, row.Reason) + if o.Details { + details, show := detailsToString(row.Details, o.LogLevel) + if show { + x = append(x, details) + } + } + x = append(x, doc) + if o.Annotations { + reasons := row.Annotations(res.Config) + x = append(x, strings.Join(reasons, "\n")) + } + data = append(data, x) + } + } + + table := tablewriter.NewWriter(writer) + header := []string{"REPO", "AGGREGATED SCORE", "Score", "Name", "Reason"} + if o.Details { + header = append(header, "Details") + } + header = append(header, "Documentation/Remediation") + if o.Annotations { + header = append(header, "Annotation") + } + table.SetHeader(header) + table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true}) + table.SetRowSeparator("-") + table.SetRowLine(true) + table.SetCenterSeparator("|") + table.AppendBulk(data) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetRowLine(true) + table.Render() + + return nil +} + //nolint:gocognit,gocyclo // nothing better to do right now func assignRawData(probeCheckName string, request *checker.CheckRequest, ret *Result) error { switch probeCheckName {