Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 285 additions & 76 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,107 +15,316 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"html/template"
stdlog "log"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"

"github.com/spf13/cobra"

"github.com/ossf/scorecard/v5/clients/githubrepo"
"github.com/ossf/scorecard/v5/clients/ossfuzz"
"github.com/ossf/scorecard/v5/checker"
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/clients/localdir"
pmc "github.com/ossf/scorecard/v5/cmd/internal/packagemanager"
docs "github.com/ossf/scorecard/v5/docs/checks"
"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"
)

// TODO(cmd): Determine if this should be exported.
type server struct {
logger *log.Logger
opts *options.Options
}

type scorecardRequest struct {

Check failure on line 48 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

fieldalignment: struct with 232 pointer bytes could be 208 (govet)
Repo string `json:"repo"`
Local string `json:"local,omitempty"`
NPM string `json:"npm,omitempty"`
PyPI string `json:"pypi,omitempty"`
RubyGems string `json:"rubygems,omitempty"`
Nuget string `json:"nuget,omitempty"`
Checks []string `json:"checks,omitempty"`
Commit string `json:"commit,omitempty"`
CommitDepth int `json:"commit_depth,omitempty"`
ShowDetails bool `json:"show_details,omitempty"`
Format string `json:"format,omitempty"`
LogLevel string `json:"log_level,omitempty"`
Probes []string `json:"probes,omitempty"`
FileMode string `json:"file_mode,omitempty"`
PolicyFile string `json:"policy_file,omitempty"`
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the CLI, policy file is a local file. How do we expect to pass a policy file to a server, with a URI?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm late. I rarely use this policy file. I think that it should be a cmd parameter rather than a server parameter, so now I've temporarily removed it.


func newServer(logger *log.Logger, opts *options.Options) *server {
return &server{
logger: logger,
opts: opts,
}

Check warning on line 70 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L66-L70

Added lines #L66 - L70 were not covered by tests
}

func (s *server) handleScorecard(w http.ResponseWriter, r *http.Request) {
var req scorecardRequest
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
} else {
req.Repo = r.URL.Query().Get("repo")
req.Local = r.URL.Query().Get("local")
req.NPM = r.URL.Query().Get("npm")
req.PyPI = r.URL.Query().Get("pypi")
req.RubyGems = r.URL.Query().Get("rubygems")
req.Nuget = r.URL.Query().Get("nuget")
req.Checks = strings.Split(r.URL.Query().Get("checks"), ",")
req.Commit = r.URL.Query().Get("commit")
req.ShowDetails = r.URL.Query().Get("show_details") == "true"
req.Format = r.URL.Query().Get("format")
req.LogLevel = r.URL.Query().Get("log_level")
req.Probes = strings.Split(r.URL.Query().Get("probes"), ",")
req.FileMode = r.URL.Query().Get("file_mode")
req.PolicyFile = r.URL.Query().Get("policy_file")
}

Check warning on line 95 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L73-L95

Added lines #L73 - L95 were not covered by tests

// Set options
s.opts.Repo = req.Repo
s.opts.Local = req.Local
s.opts.NPM = req.NPM
s.opts.PyPI = req.PyPI
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now we have one options.Options, which is passed in serveCmd, and then this one copy is modified for each request. This seems like a race condition.

we should create a new struct for each request. either through options.New, or manually if we want to avoid the env var parsing.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. options.New would be a better choice. Have fixed it.

s.opts.RubyGems = req.RubyGems
s.opts.Nuget = req.Nuget
s.opts.Commit = req.Commit
if s.opts.Commit == "" {
s.opts.Commit = clients.HeadSHA
}
s.opts.CommitDepth = req.CommitDepth
s.opts.ShowDetails = req.ShowDetails
s.opts.Format = req.Format
if s.opts.Format == "" {
s.opts.Format = options.FormatDefault
}
s.opts.LogLevel = req.LogLevel
if s.opts.LogLevel == "" {
s.opts.LogLevel = "info"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.InfoLevel is a constant with this value.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I have fixed it.

}
s.opts.FileMode = req.FileMode
if s.opts.FileMode == "" {
s.opts.FileMode = options.FileModeArchive
} else if s.opts.FileMode != options.FileModeArchive && s.opts.FileMode != options.FileModeGit {
http.Error(w, fmt.Sprintf("unsupported file mode: %s", s.opts.FileMode), http.StatusBadRequest)
return
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this validation is already covered by the s.opts.Validate call below

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. It has been removed.

s.opts.PolicyFile = req.PolicyFile
s.opts.ChecksToRun = req.Checks

// Validate options
if err := s.opts.Validate(); err != nil {
http.Error(w, fmt.Sprintf("invalid options: %v", err), http.StatusBadRequest)
return
}

Check warning on line 132 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L98-L132

Added lines #L98 - L132 were not covered by tests

p := &pmc.PackageManagerClient{}
// Set repo from package managers
pkgResp, err := fetchGitRepositoryFromPackageManagers(s.opts.NPM, s.opts.PyPI, s.opts.RubyGems, s.opts.Nuget, p)
if err != nil {
http.Error(w, fmt.Sprintf("fetchGitRepositoryFromPackageManagers: %v", err), http.StatusInternalServerError)
return
}
if pkgResp.exists {
s.opts.Repo = pkgResp.associatedRepo
}

Check warning on line 143 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L134-L143

Added lines #L134 - L143 were not covered by tests

pol, err := policy.ParseFromFile(s.opts.PolicyFile)
if err != nil {
http.Error(w, fmt.Sprintf("readPolicy: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 149 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L145-L149

Added lines #L145 - L149 were not covered by tests

ctx := r.Context()

var repo clients.Repo
if s.opts.Local != "" {
repo, err = localdir.MakeLocalDirRepo(s.opts.Local)
if err != nil {
http.Error(w, fmt.Sprintf("making local dir: %v", err), http.StatusInternalServerError)
return
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we expect people to pass the serve command repos which are local to the server?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error also stems from my naive approach of simply copying the logic from root.go. When the server starts up, the repo should be a parameter provided by the client's request. Now local would be not supported.

} else {
repo, err = makeRepo(s.opts.Repo)
if err != nil {
http.Error(w, fmt.Sprintf("making remote repo: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 165 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L151-L165

Added lines #L151 - L165 were not covered by tests
}

// Read docs
checkDocs, err := docs.Read()
if err != nil {
http.Error(w, fmt.Sprintf("cannot read yaml file: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 173 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L169-L173

Added lines #L169 - L173 were not covered by tests

var requiredRequestTypes []checker.RequestType
if s.opts.Local != "" {
requiredRequestTypes = append(requiredRequestTypes, checker.FileBased)
}
if !strings.EqualFold(s.opts.Commit, clients.HeadSHA) {
requiredRequestTypes = append(requiredRequestTypes, checker.CommitBased)
}

Check warning on line 181 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L175-L181

Added lines #L175 - L181 were not covered by tests

enabledChecks, err := policy.GetEnabled(pol, s.opts.Checks(), requiredRequestTypes)
stdlog.Printf("DEBUG: enabledChecks = %#v", enabledChecks)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be removed? was this for your testing?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Sorry for my carelessness. Done removing.

if err != nil {
http.Error(w, fmt.Sprintf("GetEnabled: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 188 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L183-L188

Added lines #L183 - L188 were not covered by tests

checks := make([]string, 0, len(enabledChecks))
for c := range enabledChecks {
checks = append(checks, c)
}

Check warning on line 193 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L190-L193

Added lines #L190 - L193 were not covered by tests

enabledProbes := s.opts.Probes()

opts := []scorecard.Option{
scorecard.WithLogLevel(log.ParseLevel(s.opts.LogLevel)),
scorecard.WithCommitSHA(s.opts.Commit),
scorecard.WithCommitDepth(s.opts.CommitDepth),
scorecard.WithProbes(enabledProbes),
scorecard.WithChecks(checks),
}

if strings.EqualFold(s.opts.FileMode, options.FileModeGit) {
opts = append(opts, scorecard.WithFileModeGit())
}

Check warning on line 207 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L195-L207

Added lines #L195 - L207 were not covered by tests

repoResult, err := scorecard.Run(ctx, repo, opts...)
if err != nil {
s.logger.Error(err, "scorecard.Run")
http.Error(w, fmt.Sprintf("scorecard.Run: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 214 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L209-L214

Added lines #L209 - L214 were not covered by tests

repoResult.Metadata = append(repoResult.Metadata, s.opts.Metadata...)

// Sort by name
sort.Slice(repoResult.Checks, func(i, j int) bool {
return repoResult.Checks[i].Name < repoResult.Checks[j].Name
})

Check warning on line 221 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L216-L221

Added lines #L216 - L221 were not covered by tests

// Return results
w.Header().Set("Content-Type", "application/json")
if err := repoResult.AsJSON2(w, checkDocs, &scorecard.AsJSON2ResultOption{
LogLevel: log.ParseLevel(s.opts.LogLevel),
Details: s.opts.ShowDetails,
Annotations: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we would probably want ShowAnnotations as a query parameter.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I've added ShowAnnotations as a query parameter now.

}); err != nil {
s.logger.Error(err, "writing JSON response")
http.Error(w, fmt.Sprintf("failed to format results: %v", err), http.StatusInternalServerError)
return
}

Check warning on line 233 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L224-L233

Added lines #L224 - L233 were not covered by tests
}

// CORS middleware for net/http

Check failure on line 236 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

Comment should end in a period (godot)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)

Check warning on line 246 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L237-L246

Added lines #L237 - L246 were not covered by tests
})
}

// Recover middleware for net/http

Check failure on line 250 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

Comment should end in a period (godot)
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
stdlog.Printf("panic: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}

Check warning on line 257 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L251-L257

Added lines #L251 - L257 were not covered by tests
}()
next.ServeHTTP(w, r)

Check warning on line 259 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L259

Added line #L259 was not covered by tests
})
}

// Logger middleware for net/http

Check failure on line 263 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

Comment should end in a period (godot)
func loggerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
stdlog.Printf("%s %s %s", r.Method, r.URL, time.Since(start))

Check failure

Code scanning / CodeQL

Log entries created from user input High

This log entry depends on a
user-provided value
.
})

Check warning on line 269 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L264-L269

Added lines #L264 - L269 were not covered by tests
}

func serveCmd(o *options.Options) *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Serve the scorecard program over http",
Long: ``,
Run: func(cmd *cobra.Command, args []string) {
Long: `Start an HTTP server to run scorecard checks on repositories with REST API support.`,
RunE: func(cmd *cobra.Command, args []string) error {

Check warning on line 277 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L276-L277

Added lines #L276 - L277 were not covered by tests
logger := log.NewLogger(log.ParseLevel(o.LogLevel))
srv := newServer(logger, o)

Check warning on line 279 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L279

Added line #L279 was not covered by tests

t, err := template.New("webpage").Parse(tpl)
if err != nil {
// TODO(log): Should this actually panic?
logger.Error(err, "parsing webpage template")
panic(err)
}

http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
repoParam := r.URL.Query().Get("repo")
const length = 3
s := strings.SplitN(repoParam, "/", length)
if len(s) != length {
rw.WriteHeader(http.StatusBadRequest)
}
repo, err := githubrepo.MakeGithubRepo(repoParam)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
}
ctx := r.Context()
repoClient := githubrepo.CreateGithubRepoClient(ctx, logger)
ossFuzzRepoClient, err := ossfuzz.CreateOSSFuzzClientEager(ossfuzz.StatusURL)
if err != nil {
logger.Error(err, "initializing clients")
rw.WriteHeader(http.StatusInternalServerError)
}
defer ossFuzzRepoClient.Close()
repoResult, err := scorecard.Run(ctx, repo,
scorecard.WithCommitDepth(o.CommitDepth),
scorecard.WithRepoClient(repoClient),
scorecard.WithOSSFuzzClient(ossFuzzRepoClient),
)
if err != nil {
logger.Error(err, "running enabled scorecard checks on repo")
rw.WriteHeader(http.StatusInternalServerError)
}

if r.Header.Get("Content-Type") == "application/json" {
if err := repoResult.AsJSON(o.ShowDetails, log.ParseLevel(o.LogLevel), rw); err != nil {
// TODO(log): Improve error message
logger.Error(err, "")
rw.WriteHeader(http.StatusInternalServerError)
}
return
}
if err := t.Execute(rw, repoResult); err != nil {
// TODO(log): Improve error message
logger.Error(err, "")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodPost {
srv.handleScorecard(w, r)
} else {
http.NotFound(w, r)

Check warning on line 286 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L281-L286

Added lines #L281 - L286 were not covered by tests
}
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

Check warning on line 291 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L289-L291

Added lines #L289 - L291 were not covered by tests

// Compose middlewares: logger -> recover -> cors -> mux
handler := loggerMiddleware(recoverMiddleware(corsMiddleware(mux)))

Check warning on line 295 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L294-L295

Added lines #L294 - L295 were not covered by tests
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
logger.Info("Listening on localhost:" + port + "\n")
//nolint:gosec // unused.
err = http.ListenAndServe(fmt.Sprintf("0.0.0.0:%s", port), nil)
if err != nil {
// TODO(log): Should this actually panic?
logger.Error(err, "listening and serving")
panic(err)

httpServer := &http.Server{

Check failure on line 301 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

G112: Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server (gosec)
Addr: fmt.Sprintf("0.0.0.0:%s", port),
Handler: handler,
}

// Graceful shutdown
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

go func() {
logger.Info("Server starting on port " + port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {

Check failure on line 312 in cmd/serve.go

View workflow job for this annotation

GitHub Actions / check-linter

do not compare errors directly "err != http.ErrServerClosed", use "!errors.Is(err, http.ErrServerClosed)" instead (err113)
logger.Error(err, "server error")
}

Check warning on line 314 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L301-L314

Added lines #L301 - L314 were not covered by tests
}()

<-done
logger.Info("Shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown: %w", err)

Check warning on line 324 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L317-L324

Added lines #L317 - L324 were not covered by tests
}

return nil

Check warning on line 327 in cmd/serve.go

View check run for this annotation

Codecov / codecov/patch

cmd/serve.go#L327

Added line #L327 was not covered by tests
},
}
}

const tpl = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Scorecard Results for: {{.Repo}}</title>
</head>
<body>
{{range .Checks}}
<div>
<p>{{ .Name }}: {{ .Pass }}</p>
</div>
{{end}}
</body>
</html>`
Loading