Skip to content
1 change: 0 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions pkg/certifier/scorecard/scorecard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
}
Expand Down Expand Up @@ -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
}
}
106 changes: 105 additions & 1 deletion pkg/certifier/scorecard/scorecardRunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -94,4 +198,4 @@ func NewScorecardRunner(ctx context.Context) (Scorecard, error) {
return scorecardRunner{
ctx,
}, nil
}
}
62 changes: 62 additions & 0 deletions pkg/certifier/scorecard/scorecardRunner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package scorecard
import (
"context"
"os"
"strings"
"testing"
)

Expand Down Expand Up @@ -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)
}
})
}
}
3 changes: 2 additions & 1 deletion pkg/certifier/scorecard/scorecard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading