Skip to content

Commit 845216e

Browse files
gaganhr94shreyasHpandyaAgrek11ANIRUDH-333
committed
feat: adds custom scorecard certifier
Co-authored-by: Shreyas Pandya <pandyashreyas1@gmail.com> Co-authored-by: Abhisek Agrawal <abhishek.yours4@gmail.com> Co-authored-by: Anirudh Edpuganti <aniedpuganti@gmail.com> Co-authored-by: Gagan H R <hrgagan4@gmail.com>
1 parent 9ead9e8 commit 845216e

File tree

4 files changed

+176
-5
lines changed

4 files changed

+176
-5
lines changed

pkg/certifier/scorecard/scorecard.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/guacsec/guac/pkg/certifier"
2525
"github.com/guacsec/guac/pkg/certifier/components/source"
2626
"github.com/guacsec/guac/pkg/events"
27+
"github.com/guacsec/guac/pkg/logging"
2728
"github.com/ossf/scorecard/v4/docs/checks"
2829
"github.com/ossf/scorecard/v4/log"
2930

@@ -39,7 +40,7 @@ type scorecard struct {
3940
var ErrArtifactNodeTypeMismatch = fmt.Errorf("rootComponent type is not *source.SourceNode")
4041

4142
// CertifyComponent is a certifier that generates scorecard attestations
42-
func (s scorecard) CertifyComponent(_ context.Context, rootComponent interface{}, docChannel chan<- *processor.Document) error {
43+
func (s scorecard) CertifyComponent(ctx context.Context, rootComponent interface{}, docChannel chan<- *processor.Document) error {
4344
if docChannel == nil {
4445
return fmt.Errorf("docChannel cannot be nil")
4546
}
@@ -110,11 +111,14 @@ func NewScorecardCertifier(sc Scorecard) (certifier.Certifier, error) {
110111
// check if the GITHUB_AUTH_TOKEN is set
111112
s, ok := os.LookupEnv("GITHUB_AUTH_TOKEN")
112113
if !ok || s == "" {
113-
return nil, fmt.Errorf("GITHUB_AUTH_TOKEN is not set")
114+
// Log warning but allow initialization without token
115+
// The API path will still work, only local computation will fail
116+
logger := logging.FromContext(context.Background())
117+
logger.Warnf("GITHUB_AUTH_TOKEN not set - scorecard API will work, but local computation fallback will be disabled")
114118
}
115119

116120
return &scorecard{
117121
scorecard: sc,
118122
ghToken: s,
119123
}, nil
120-
}
124+
}

pkg/certifier/scorecard/scorecardRunner.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ package scorecard
1818
import (
1919
"context"
2020
"fmt"
21+
"io"
22+
"net/http"
23+
"net/url"
24+
"os"
25+
"time"
2126

27+
"github.com/guacsec/guac/pkg/logging"
2228
"github.com/ossf/scorecard/v4/checker"
2329
"github.com/ossf/scorecard/v4/checks"
2430
"github.com/ossf/scorecard/v4/log"
@@ -31,6 +37,102 @@ type scorecardRunner struct {
3137
}
3238

3339
func (s scorecardRunner) GetScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
40+
logger := logging.FromContext(s.ctx)
41+
42+
// First try API approach
43+
logger.Debugf("Attempting to fetch scorecard from API for repo: %s, commit: %s", repoName, commitSHA)
44+
result, err := s.getScoreFromAPI(repoName, commitSHA, tag)
45+
if err == nil {
46+
logger.Infof("Successfully fetched scorecard from API for repo: %s", repoName)
47+
return result, nil
48+
}
49+
50+
// Log API failure and check if we can fallback to local computation
51+
logger.Warnf("API fetch failed for repo %s: %v", repoName, err)
52+
53+
// Check if GitHub token is available for local computation
54+
if _, ok := os.LookupEnv("GITHUB_AUTH_TOKEN"); !ok {
55+
logger.Errorf("Cannot fall back to local computation - GITHUB_AUTH_TOKEN not set")
56+
return nil, fmt.Errorf("scorecard API failed and GITHUB_AUTH_TOKEN not available for local computation: %w", err)
57+
}
58+
59+
logger.Infof("Falling back to local computation for repo: %s", repoName)
60+
result, err = s.computeScore(repoName, commitSHA, tag)
61+
if err != nil {
62+
logger.Errorf("Failed to compute scorecard locally for repo %s: %v", repoName, err)
63+
}
64+
return result, err
65+
}
66+
67+
func (s scorecardRunner) getScoreFromAPI(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
68+
logger := logging.FromContext(s.ctx)
69+
70+
// The Scorecard API only supports commit SHAs, not tags.
71+
// If a tag is provided without a commitSHA, we cannot use the API
72+
// and must fall back to local computation to avoid returning incorrect results.
73+
if (commitSHA == "" || commitSHA == "HEAD") && tag != "" {
74+
logger.Debugf("Cannot use API for tag %s without commit SHA - will fall back to local computation", tag)
75+
return nil, fmt.Errorf("scorecard API does not support tags; commit SHA required for tag %s", tag)
76+
}
77+
78+
url, err := url.JoinPath("https://api.securityscorecards.dev", "projects", repoName)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
if commitSHA != "" && commitSHA != "HEAD" {
84+
url += "?commit=" + commitSHA
85+
}
86+
87+
logger.Debugf("Making API request to: %s", url)
88+
89+
httpClient := &http.Client{
90+
Timeout: 30 * time.Second,
91+
}
92+
93+
req, err := http.NewRequestWithContext(s.ctx, http.MethodGet, url, nil)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to create request: %w", err)
96+
}
97+
98+
req.Header.Set("User-Agent", "guac-scorecard-certifier/1.0")
99+
req.Header.Set("Accept", "application/json")
100+
101+
resp, err := httpClient.Do(req)
102+
if err != nil {
103+
return nil, fmt.Errorf("scorecard request failed: %w", err)
104+
}
105+
defer func() {
106+
if resp != nil && resp.Body != nil {
107+
_ = resp.Body.Close()
108+
}
109+
}()
110+
111+
logger.Debugf("API response status code: %d", resp.StatusCode)
112+
113+
if resp.StatusCode == http.StatusNotFound {
114+
return nil, fmt.Errorf("Scorecard for repo %s not found in scorecard API", repoName)
115+
}
116+
117+
if resp.StatusCode >= 400 {
118+
body, _ := io.ReadAll(resp.Body)
119+
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
120+
}
121+
122+
// Use scorecard's built-in JSON parser, which is experimental
123+
// but still better then rolling out your own type
124+
result, _, err := sc.ExperimentalFromJSON2(resp.Body)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to decode API response: %w", err)
127+
}
128+
129+
return &result, nil
130+
}
131+
132+
func (s scorecardRunner) computeScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
133+
logger := logging.FromContext(s.ctx)
134+
logger.Infof("Starting local scorecard computation for repo: %s, commit: %s, tag: %s", repoName, commitSHA, tag)
135+
34136
// Can't use guacs standard logger because scorecard uses a different logger.
35137
defaultLogger := log.NewLogger(log.DefaultLevel)
36138
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
79181
}
80182
}
81183

184+
logger.Debugf("Running %d scorecard checks locally", len(enabledChecks))
82185
res, err := sc.RunScorecard(s.ctx, repo, commitSHA, 0, enabledChecks, repoClient, ossFuzzClient, ciiClient, vulnsClient)
83186
if err != nil {
84187
return nil, fmt.Errorf("error, failed to run scorecard: %w", err)
85188
}
189+
86190
if res.Repo.Name == "" {
87191
// The commit SHA can be invalid or the repo can be private.
88192
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) {
94198
return scorecardRunner{
95199
ctx,
96200
}, nil
97-
}
201+
}

pkg/certifier/scorecard/scorecardRunner_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package scorecard
2020
import (
2121
"context"
2222
"os"
23+
"strings"
2324
"testing"
2425
)
2526

@@ -64,3 +65,64 @@ func Test_scorecardRunner_GetScore(t *testing.T) {
6465
})
6566
}
6667
}
68+
69+
// Test_scorecardRunner_getScoreFromAPI tests the early return logic
70+
// for tags without commit SHAs, which doesn't require network access.
71+
72+
func Test_scorecardRunner_getScoreFromAPI(t *testing.T) {
73+
tests := []struct {
74+
name string
75+
repoName string
76+
commitSHA string
77+
tag string
78+
wantErr bool
79+
errContains string
80+
}{
81+
{
82+
name: "tag without commit SHA returns error",
83+
repoName: "test/repo",
84+
commitSHA: "",
85+
tag: "v1.0.0",
86+
wantErr: true,
87+
errContains: "scorecard API does not support tags",
88+
},
89+
{
90+
name: "tag with HEAD commit SHA returns error",
91+
repoName: "test/repo",
92+
commitSHA: "HEAD",
93+
tag: "v1.0.0",
94+
wantErr: true,
95+
errContains: "scorecard API does not support tags",
96+
},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
ctx := context.Background()
102+
runner := scorecardRunner{ctx: ctx}
103+
104+
// Run the actual API call - these tests only cover edge cases
105+
// that return early without making network requests
106+
got, err := runner.getScoreFromAPI(tt.repoName, tt.commitSHA, tt.tag)
107+
108+
if (err != nil) != tt.wantErr {
109+
t.Errorf("getScoreFromAPI() error = %v, wantErr %v", err, tt.wantErr)
110+
return
111+
}
112+
113+
if err != nil && tt.errContains != "" {
114+
if !strings.Contains(err.Error(), tt.errContains) {
115+
t.Errorf("getScoreFromAPI() error = %v, should contain %v", err, tt.errContains)
116+
}
117+
}
118+
119+
if !tt.wantErr && got == nil {
120+
t.Errorf("getScoreFromAPI() returned nil result without error")
121+
}
122+
123+
if !tt.wantErr && got != nil {
124+
t.Logf("Successfully fetched scorecard for %s", got.Repo.Name)
125+
}
126+
})
127+
}
128+
}

pkg/certifier/scorecard/scorecard_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ func TestNewScorecard(t *testing.T) {
6161
sc: mockScorecard{},
6262
authToken: "",
6363
wantAuthToken: true,
64-
wantErr: true,
64+
wantErr: false,
65+
want: &scorecard{scorecard: mockScorecard{}, ghToken: ""},
6566
},
6667
}
6768
for _, test := range tests {

0 commit comments

Comments
 (0)