diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 33b6c14d4d..842a7d786a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -57,7 +57,6 @@ jobs: uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # main - name: Install syft uses: anchore/sbom-action/download-syft@aa0e114b2e19480f157109b9922bda359bd98b90 # v0.20.8 - - name: Run GoReleaser Snapshot if: ${{ !startsWith(github.ref, 'refs/tags/') }} id: run-goreleaser-snapshot diff --git a/pkg/certifier/scorecard/scorecard.go b/pkg/certifier/scorecard/scorecard.go index 1820dfa010..81450513e2 100644 --- a/pkg/certifier/scorecard/scorecard.go +++ b/pkg/certifier/scorecard/scorecard.go @@ -24,6 +24,7 @@ import ( "github.com/guacsec/guac/pkg/certifier" "github.com/guacsec/guac/pkg/certifier/components/source" "github.com/guacsec/guac/pkg/events" + "github.com/guacsec/guac/pkg/logging" "github.com/ossf/scorecard/v4/docs/checks" "github.com/ossf/scorecard/v4/log" @@ -39,7 +40,7 @@ type scorecard struct { var ErrArtifactNodeTypeMismatch = fmt.Errorf("rootComponent type is not *source.SourceNode") // CertifyComponent is a certifier that generates scorecard attestations -func (s scorecard) CertifyComponent(_ context.Context, rootComponent interface{}, docChannel chan<- *processor.Document) error { +func (s scorecard) CertifyComponent(ctx context.Context, rootComponent interface{}, docChannel chan<- *processor.Document) error { if docChannel == nil { return fmt.Errorf("docChannel cannot be nil") } @@ -110,11 +111,14 @@ func NewScorecardCertifier(sc Scorecard) (certifier.Certifier, error) { // check if the GITHUB_AUTH_TOKEN is set s, ok := os.LookupEnv("GITHUB_AUTH_TOKEN") if !ok || s == "" { - return nil, fmt.Errorf("GITHUB_AUTH_TOKEN is not set") + // Log warning but allow initialization without token + // The API path will still work, only local computation will fail + logger := logging.FromContext(context.Background()) + logger.Warnf("GITHUB_AUTH_TOKEN not set - scorecard API will work, but local computation fallback will be disabled") } return &scorecard{ scorecard: sc, ghToken: s, }, nil -} +} \ No newline at end of file diff --git a/pkg/certifier/scorecard/scorecardRunner.go b/pkg/certifier/scorecard/scorecardRunner.go index 9cad48515f..dec4babf0d 100644 --- a/pkg/certifier/scorecard/scorecardRunner.go +++ b/pkg/certifier/scorecard/scorecardRunner.go @@ -18,7 +18,13 @@ package scorecard import ( "context" "fmt" + "io" + "net/http" + "net/url" + "os" + "time" + "github.com/guacsec/guac/pkg/logging" "github.com/ossf/scorecard/v4/checker" "github.com/ossf/scorecard/v4/checks" "github.com/ossf/scorecard/v4/log" @@ -31,6 +37,102 @@ type scorecardRunner struct { } func (s scorecardRunner) GetScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) { + logger := logging.FromContext(s.ctx) + + // First try API approach + logger.Debugf("Attempting to fetch scorecard from API for repo: %s, commit: %s", repoName, commitSHA) + result, err := s.getScoreFromAPI(repoName, commitSHA, tag) + if err == nil { + logger.Infof("Successfully fetched scorecard from API for repo: %s", repoName) + return result, nil + } + + // Log API failure and check if we can fallback to local computation + logger.Warnf("API fetch failed for repo %s: %v", repoName, err) + + // Check if GitHub token is available for local computation + if _, ok := os.LookupEnv("GITHUB_AUTH_TOKEN"); !ok { + logger.Errorf("Cannot fall back to local computation - GITHUB_AUTH_TOKEN not set") + return nil, fmt.Errorf("scorecard API failed and GITHUB_AUTH_TOKEN not available for local computation: %w", err) + } + + logger.Infof("Falling back to local computation for repo: %s", repoName) + result, err = s.computeScore(repoName, commitSHA, tag) + if err != nil { + logger.Errorf("Failed to compute scorecard locally for repo %s: %v", repoName, err) + } + return result, err +} + +func (s scorecardRunner) getScoreFromAPI(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) { + logger := logging.FromContext(s.ctx) + + // The Scorecard API only supports commit SHAs, not tags. + // If a tag is provided without a commitSHA, we cannot use the API + // and must fall back to local computation to avoid returning incorrect results. + if (commitSHA == "" || commitSHA == "HEAD") && tag != "" { + logger.Debugf("Cannot use API for tag %s without commit SHA - will fall back to local computation", tag) + return nil, fmt.Errorf("scorecard API does not support tags; commit SHA required for tag %s", tag) + } + + url, err := url.JoinPath("https://api.securityscorecards.dev", "projects", "github.com", repoName) + if err != nil { + return nil, err + } + + if commitSHA != "" && commitSHA != "HEAD" { + url += "?commit=" + commitSHA + } + + logger.Debugf("Making API request to: %s", url) + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(s.ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "guac-scorecard-certifier/1.0") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("scorecard request failed: %w", err) + } + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + logger.Debugf("API response status code: %d", resp.StatusCode) + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("Scorecard for repo %s not found in scorecard API", repoName) + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Use scorecard's built-in JSON parser, which is experimental + // but still better then rolling out your own type + result, _, err := sc.ExperimentalFromJSON2(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to decode API response: %w", err) + } + + return &result, nil +} + +func (s scorecardRunner) computeScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) { + logger := logging.FromContext(s.ctx) + logger.Infof("Starting local scorecard computation for repo: %s, commit: %s, tag: %s", repoName, commitSHA, tag) + // Can't use guacs standard logger because scorecard uses a different logger. defaultLogger := log.NewLogger(log.DefaultLevel) repo, repoClient, ossFuzzClient, ciiClient, vulnsClient, err := checker.GetClients(s.ctx, repoName, "", defaultLogger) @@ -79,10 +181,12 @@ func (s scorecardRunner) GetScore(repoName, commitSHA, tag string) (*sc.Scorecar } } + logger.Debugf("Running %d scorecard checks locally", len(enabledChecks)) res, err := sc.RunScorecard(s.ctx, repo, commitSHA, 0, enabledChecks, repoClient, ossFuzzClient, ciiClient, vulnsClient) if err != nil { return nil, fmt.Errorf("error, failed to run scorecard: %w", err) } + if res.Repo.Name == "" { // The commit SHA can be invalid or the repo can be private. return nil, fmt.Errorf("error, failed to get scorecard data for repo %v, commit SHA %v", res.Repo.Name, commitSHA) @@ -94,4 +198,4 @@ func NewScorecardRunner(ctx context.Context) (Scorecard, error) { return scorecardRunner{ ctx, }, nil -} +} \ No newline at end of file diff --git a/pkg/certifier/scorecard/scorecardRunner_test.go b/pkg/certifier/scorecard/scorecardRunner_test.go index 8c11d21627..bc4b2bb97f 100644 --- a/pkg/certifier/scorecard/scorecardRunner_test.go +++ b/pkg/certifier/scorecard/scorecardRunner_test.go @@ -20,6 +20,7 @@ package scorecard import ( "context" "os" + "strings" "testing" ) @@ -64,3 +65,64 @@ func Test_scorecardRunner_GetScore(t *testing.T) { }) } } + +// Test_scorecardRunner_getScoreFromAPI tests the early return logic +// for tags without commit SHAs, which doesn't require network access. + +func Test_scorecardRunner_getScoreFromAPI(t *testing.T) { + tests := []struct { + name string + repoName string + commitSHA string + tag string + wantErr bool + errContains string + }{ + { + name: "tag without commit SHA returns error", + repoName: "test/repo", + commitSHA: "", + tag: "v1.0.0", + wantErr: true, + errContains: "scorecard API does not support tags", + }, + { + name: "tag with HEAD commit SHA returns error", + repoName: "test/repo", + commitSHA: "HEAD", + tag: "v1.0.0", + wantErr: true, + errContains: "scorecard API does not support tags", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + runner := scorecardRunner{ctx: ctx} + + // Run the actual API call - these tests only cover edge cases + // that return early without making network requests + got, err := runner.getScoreFromAPI(tt.repoName, tt.commitSHA, tt.tag) + + if (err != nil) != tt.wantErr { + t.Errorf("getScoreFromAPI() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err != nil && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("getScoreFromAPI() error = %v, should contain %v", err, tt.errContains) + } + } + + if !tt.wantErr && got == nil { + t.Errorf("getScoreFromAPI() returned nil result without error") + } + + if !tt.wantErr && got != nil { + t.Logf("Successfully fetched scorecard for %s", got.Repo.Name) + } + }) + } +} diff --git a/pkg/certifier/scorecard/scorecard_test.go b/pkg/certifier/scorecard/scorecard_test.go index 4acd05b498..8ab4d145a5 100644 --- a/pkg/certifier/scorecard/scorecard_test.go +++ b/pkg/certifier/scorecard/scorecard_test.go @@ -61,7 +61,8 @@ func TestNewScorecard(t *testing.T) { sc: mockScorecard{}, authToken: "", wantAuthToken: true, - wantErr: true, + wantErr: false, + want: &scorecard{scorecard: mockScorecard{}, ghToken: ""}, }, } for _, test := range tests {