Skip to content

Commit 15c5e84

Browse files
gaganhr94shreyasHpandyaAgrek11ANIRUDH-333Jayashree138
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: Jayashree O <jaishu138@gmail.com> Co-authored-by: Paul Joseph <k.paul.joseph@gmail.com> Co-authored-by: Gagan H R <hrgagan4@gmail.com> Signed-off-by: Gagan H R <hrgagan4@gmail.com>
1 parent c715000 commit 15c5e84

File tree

4 files changed

+222
-5
lines changed

4 files changed

+222
-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: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,147 @@ package scorecard
1818
import (
1919
"context"
2020
"fmt"
21-
21+
"github.com/guacsec/guac/pkg/logging"
2222
"github.com/ossf/scorecard/v4/checker"
2323
"github.com/ossf/scorecard/v4/checks"
2424
"github.com/ossf/scorecard/v4/log"
2525
sc "github.com/ossf/scorecard/v4/pkg"
26+
"io"
27+
"net/http"
28+
"net/url"
29+
"os"
30+
"strings"
31+
"time"
2632
)
2733

34+
const githubPrefix = "github.com/"
35+
2836
// scorecardRunner is a struct that implements the Scorecard interface.
2937
type scorecardRunner struct {
3038
ctx context.Context
3139
}
3240

41+
// normalizeRepoName ensures the repo name has the github.com/ prefix required by the Scorecard API.
42+
// If the prefix is missing, it is added. If the repo name already has the prefix, it is returned as-is.
43+
func normalizeRepoName(repoName string) string {
44+
if strings.HasPrefix(repoName, githubPrefix) {
45+
return repoName
46+
}
47+
return githubPrefix + repoName
48+
}
49+
3350
func (s scorecardRunner) GetScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
51+
logger := logging.FromContext(s.ctx)
52+
repoName = normalizeRepoName(repoName)
53+
54+
// First try API approach
55+
logger.Debugf("Attempting to fetch scorecard from API for repo: %s, commit: %s", repoName, commitSHA)
56+
result, err := s.getScoreFromAPI(repoName, commitSHA, tag)
57+
if err == nil {
58+
logger.Infof("Successfully fetched scorecard from API for repo: %s", repoName)
59+
return result, nil
60+
}
61+
62+
// Log API failure and check if we can fallback to local computation
63+
logger.Warnf("API fetch failed for repo %s: %v", repoName, err)
64+
65+
// Check if GitHub token is available for local computation
66+
if _, ok := os.LookupEnv("GITHUB_AUTH_TOKEN"); !ok {
67+
logger.Errorf("Cannot fall back to local computation - GITHUB_AUTH_TOKEN not set")
68+
return nil, fmt.Errorf("scorecard API failed and GITHUB_AUTH_TOKEN not available for local computation: %w", err)
69+
}
70+
71+
logger.Infof("Falling back to local computation for repo: %s", repoName)
72+
result, err = s.computeScore(repoName, commitSHA, tag)
73+
if err != nil {
74+
logger.Errorf("Failed to compute scorecard locally for repo %s: %v", repoName, err)
75+
}
76+
return result, err
77+
}
78+
79+
func (s scorecardRunner) getScoreFromAPI(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
80+
logger := logging.FromContext(s.ctx)
81+
82+
// If tag is provided without a valid commitSHA, skip API and use local computation
83+
// The API cannot resolve tags, but computeScore can look up the commit for a tag
84+
if (commitSHA == "" || commitSHA == "HEAD") && tag != "" {
85+
logger.Debugf("Tag %s provided without commit SHA - skipping API, will use local computation", tag)
86+
return nil, fmt.Errorf("tag provided without commit SHA; falling back to local computation for tag %s", tag)
87+
}
88+
89+
baseURL, err := url.JoinPath("https://api.securityscorecards.dev", "projects", repoName)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
// If commitSHA is provided, try with it first
95+
if commitSHA != "" && commitSHA != "HEAD" {
96+
urlWithCommit := baseURL + "?commit=" + commitSHA
97+
result, err := s.fetchFromAPI(urlWithCommit)
98+
if err == nil {
99+
return result, nil
100+
}
101+
logger.Debugf("API call with commit %s failed, retrying without commit: %v", commitSHA, err)
102+
}
103+
104+
result, err := s.fetchFromAPI(baseURL)
105+
if err != nil {
106+
return nil, err
107+
}
108+
return result, nil
109+
}
110+
111+
func (s scorecardRunner) fetchFromAPI(apiURL string) (*sc.ScorecardResult, error) {
112+
logger := logging.FromContext(s.ctx)
113+
logger.Debugf("Making API request to: %s", apiURL)
114+
115+
httpClient := &http.Client{
116+
Timeout: 30 * time.Second,
117+
}
118+
119+
req, err := http.NewRequestWithContext(s.ctx, http.MethodGet, apiURL, nil)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to create request: %w", err)
122+
}
123+
124+
req.Header.Set("User-Agent", "guac-scorecard-certifier/1.0")
125+
req.Header.Set("Accept", "application/json")
126+
127+
resp, err := httpClient.Do(req)
128+
if err != nil {
129+
return nil, fmt.Errorf("scorecard request failed: %w", err)
130+
}
131+
defer func() {
132+
if resp != nil && resp.Body != nil {
133+
_ = resp.Body.Close()
134+
}
135+
}()
136+
137+
logger.Debugf("API response status code: %d", resp.StatusCode)
138+
139+
if resp.StatusCode == http.StatusNotFound {
140+
return nil, fmt.Errorf("scorecard not found in API")
141+
}
142+
143+
if resp.StatusCode >= 400 {
144+
body, _ := io.ReadAll(resp.Body)
145+
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
146+
}
147+
148+
// Use scorecard's built-in JSON parser, which is experimental
149+
// but still better then rolling out your own type
150+
result, _, err := sc.ExperimentalFromJSON2(resp.Body)
151+
if err != nil {
152+
return nil, fmt.Errorf("failed to decode API response: %w", err)
153+
}
154+
155+
return &result, nil
156+
}
157+
158+
func (s scorecardRunner) computeScore(repoName, commitSHA, tag string) (*sc.ScorecardResult, error) {
159+
logger := logging.FromContext(s.ctx)
160+
logger.Infof("Starting local scorecard computation for repo: %s, commit: %s, tag: %s", repoName, commitSHA, tag)
161+
34162
// Can't use guacs standard logger because scorecard uses a different logger.
35163
defaultLogger := log.NewLogger(log.DefaultLevel)
36164
repo, repoClient, ossFuzzClient, ciiClient, vulnsClient, err := checker.GetClients(s.ctx, repoName, "", defaultLogger)
@@ -79,10 +207,12 @@ func (s scorecardRunner) GetScore(repoName, commitSHA, tag string) (*sc.Scorecar
79207
}
80208
}
81209

210+
logger.Debugf("Running %d scorecard checks locally", len(enabledChecks))
82211
res, err := sc.RunScorecard(s.ctx, repo, commitSHA, 0, enabledChecks, repoClient, ossFuzzClient, ciiClient, vulnsClient)
83212
if err != nil {
84213
return nil, fmt.Errorf("error, failed to run scorecard: %w", err)
85214
}
215+
86216
if res.Repo.Name == "" {
87217
// The commit SHA can be invalid or the repo can be private.
88218
return nil, fmt.Errorf("error, failed to get scorecard data for repo %v, commit SHA %v", res.Repo.Name, commitSHA)

pkg/certifier/scorecard/scorecardRunner_test.go

Lines changed: 82 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,84 @@ func Test_scorecardRunner_GetScore(t *testing.T) {
6465
})
6566
}
6667
}
68+
69+
// Test_scorecardRunner_getScoreFromAPI tests the API fetch logic with retry behavior.
70+
// Tests that require network access use a well-known repo (ossf/scorecard).
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: "valid repo without commit returns latest scorecard",
83+
repoName: "github.com/ossf/scorecard",
84+
commitSHA: "",
85+
tag: "",
86+
wantErr: false,
87+
},
88+
{
89+
name: "tag without commit SHA skips API for local computation",
90+
repoName: "github.com/ossf/scorecard",
91+
commitSHA: "",
92+
tag: "v4.10.4",
93+
wantErr: true,
94+
errContains: "tag provided without commit SHA",
95+
},
96+
{
97+
name: "tag with HEAD skips API for local computation",
98+
repoName: "github.com/ossf/scorecard",
99+
commitSHA: "HEAD",
100+
tag: "v4.10.4",
101+
wantErr: true,
102+
errContains: "tag provided without commit SHA",
103+
},
104+
{
105+
name: "non-existent repo returns error",
106+
repoName: "github.com/nonexistent/nonexistent-repo-12345",
107+
commitSHA: "",
108+
tag: "",
109+
wantErr: true,
110+
errContains: "scorecard not found in API",
111+
},
112+
{
113+
name: "invalid commit SHA falls back to latest scorecard",
114+
repoName: "github.com/ossf/scorecard",
115+
commitSHA: "0000000000000000000000000000000000000000",
116+
tag: "",
117+
wantErr: false,
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
ctx := context.Background()
124+
runner := scorecardRunner{ctx: ctx}
125+
126+
got, err := runner.getScoreFromAPI(tt.repoName, tt.commitSHA, tt.tag)
127+
128+
if (err != nil) != tt.wantErr {
129+
t.Errorf("getScoreFromAPI() error = %v, wantErr %v", err, tt.wantErr)
130+
return
131+
}
132+
133+
if err != nil && tt.errContains != "" {
134+
if !strings.Contains(err.Error(), tt.errContains) {
135+
t.Errorf("getScoreFromAPI() error = %v, should contain %v", err, tt.errContains)
136+
}
137+
}
138+
139+
if !tt.wantErr && got == nil {
140+
t.Errorf("getScoreFromAPI() returned nil result without error")
141+
}
142+
143+
if !tt.wantErr && got != nil {
144+
t.Logf("Successfully fetched scorecard for %s", got.Repo.Name)
145+
}
146+
})
147+
}
148+
}

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)