Skip to content

Commit e8f8465

Browse files
authored
✨ Support results output as in-toto statement (#4491)
* Pull in-toto/attestation Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * Add intoto option to format flag Signed-off-by: Adolfo Garcia Veytia (puerco) <[email protected]> * Make JSONScorecardResultV2 asgmt reusable Signed-off-by: Adolfo Garcia Veytia (puerco) <[email protected]> * Statement, predicate and AsInToto method to result This adds the intoto predicate type and the private statement type. Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> * AsInToto() Unit Test Adds a unit test for the attestation functions Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> --------- Signed-off-by: Adolfo García Veytia (Puerco) <[email protected]> Signed-off-by: Adolfo Garcia Veytia (puerco) <[email protected]>
1 parent 4b11525 commit e8f8465

File tree

8 files changed

+226
-7
lines changed

8 files changed

+226
-7
lines changed

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ require (
4343
github.com/gobwas/glob v0.2.3
4444
github.com/google/go-github/v53 v53.2.0
4545
github.com/google/osv-scanner v1.9.2
46+
github.com/in-toto/attestation v1.1.1
4647
github.com/mcuadros/go-jsonschema-generator v0.0.0-20200330054847-ba7a369d4303
4748
github.com/onsi/ginkgo/v2 v2.22.2
4849
github.com/otiai10/copy v1.14.1

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd/go.mod h1:
510510
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
511511
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
512512
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
513+
github.com/in-toto/attestation v1.1.1 h1:QD3d+oATQ0dFsWoNh5oT0udQ3tUrOsZZ0Fc3tSgWbzI=
514+
github.com/in-toto/attestation v1.1.1/go.mod h1:Dcq1zVwA2V7Qin8I7rgOi+i837wEf/mOZwRm047Sjys=
513515
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
514516
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
515517
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=

options/flags.go

+1
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func (o *Options) AddFlags(cmd *cobra.Command) {
195195
FormatDefault,
196196
FormatJSON,
197197
FormatProbe,
198+
FormatInToto,
198199
}
199200

200201
if o.isSarifEnabled() {

options/options.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ const (
8383
FormatDefault = "default"
8484
// FormatRaw specifies that results should be output in raw format.
8585
FormatRaw = "raw"
86+
// FormatInToyo specifies that results should be output in an in-toto statement.
87+
FormatInToto = "intoto"
8688

8789
// File Modes
8890
// FileModeGit specifies that files should be fetched using git.
@@ -255,7 +257,7 @@ func (o *Options) isV6Enabled() bool {
255257

256258
func validateFormat(format string) bool {
257259
switch format {
258-
case FormatJSON, FormatProbe, FormatSarif, FormatDefault, FormatRaw:
260+
case FormatJSON, FormatProbe, FormatSarif, FormatDefault, FormatRaw, FormatInToto:
259261
return true
260262
default:
261263
return false

pkg/scorecard/json.go

+15-6
Original file line numberDiff line numberDiff line change
@@ -128,21 +128,20 @@ func (r *Result) AsJSON(showDetails bool, logLevel log.Level, writer io.Writer)
128128
return nil
129129
}
130130

131-
// AsJSON2 exports results as JSON for new detail format.
132-
func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2ResultOption) error {
131+
func (r *Result) resultsToJSON2(checkDocs docs.Doc, opt *AsJSON2ResultOption) (JSONScorecardResultV2, error) {
133132
if opt == nil {
134133
opt = &AsJSON2ResultOption{
135134
LogLevel: log.DefaultLevel,
136135
Details: false,
137136
Annotations: false,
138137
}
139138
}
139+
140140
score, err := r.GetAggregateScore(checkDocs)
141141
if err != nil {
142-
return err
142+
return JSONScorecardResultV2{}, err
143143
}
144144

145-
encoder := json.NewEncoder(writer)
146145
out := JSONScorecardResultV2{
147146
Repo: jsonRepoV2{
148147
Name: r.Repo.Name,
@@ -160,10 +159,10 @@ func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2Resul
160159
for _, checkResult := range r.Checks {
161160
doc, e := checkDocs.GetCheck(checkResult.Name)
162161
if e != nil {
163-
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", checkResult.Name, e))
162+
return out, fmt.Errorf("GetCheck: %s: %w", checkResult.Name, e)
164163
}
165164
if doc == nil {
166-
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", checkResult.Name, errNoDoc))
165+
return out, fmt.Errorf("GetCheck: %s: %w", checkResult.Name, errNoDoc)
167166
}
168167

169168
tmpResult := jsonCheckResultV2{
@@ -190,6 +189,16 @@ func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2Resul
190189
}
191190
out.Checks = append(out.Checks, tmpResult)
192191
}
192+
return out, nil
193+
}
194+
195+
// AsJSON2 exports results as JSON for new detail format.
196+
func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2ResultOption) error {
197+
encoder := json.NewEncoder(writer)
198+
out, err := r.resultsToJSON2(checkDocs, opt)
199+
if err != nil {
200+
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
201+
}
193202

194203
if err := encoder.Encode(out); err != nil {
195204
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("encoder.Encode: %v", err))

pkg/scorecard/scorecard_result.go

+9
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ func FormatResults(
156156
LogLevel: log.ParseLevel(opts.LogLevel),
157157
}
158158
err = results.AsJSON2(output, doc, o)
159+
case options.FormatInToto:
160+
o := &AsInTotoResultOption{
161+
AsJSON2ResultOption: AsJSON2ResultOption{
162+
Details: opts.ShowDetails,
163+
Annotations: opts.ShowAnnotations,
164+
LogLevel: log.ParseLevel(opts.LogLevel),
165+
},
166+
}
167+
err = results.AsInToto(output, doc, o)
159168
case options.FormatProbe:
160169
var opts *ProbeResultOption
161170
err = results.AsProbe(output, opts)

pkg/scorecard/statement.go

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2025 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package scorecard
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"io"
21+
22+
intoto "github.com/in-toto/attestation/go/v1"
23+
24+
docs "github.com/ossf/scorecard/v5/docs/checks"
25+
sce "github.com/ossf/scorecard/v5/errors"
26+
"github.com/ossf/scorecard/v5/log"
27+
)
28+
29+
const (
30+
InTotoPredicateType = "https://scorecard.dev/result/v0.1"
31+
)
32+
33+
type statement struct {
34+
Predicate InTotoPredicate `json:"predicate"`
35+
intoto.Statement
36+
}
37+
38+
// Predicate overrides JSONScorecardResultV2 with a nullable Repo field.
39+
type InTotoPredicate struct {
40+
Repo *jsonRepoV2 `json:"repo,omitempty"`
41+
JSONScorecardResultV2
42+
}
43+
44+
// AsInTotoResultOption wraps AsJSON2ResultOption preparing it for export as an
45+
// intoto statement.
46+
type AsInTotoResultOption struct {
47+
AsJSON2ResultOption
48+
}
49+
50+
// AsStatement converts the results as an in-toto statement.
51+
func (r *Result) AsInToto(writer io.Writer, checkDocs docs.Doc, opt *AsInTotoResultOption) error {
52+
// Build the attestation subject from the result Repo.
53+
subject := intoto.ResourceDescriptor{
54+
Name: r.Repo.Name,
55+
Uri: fmt.Sprintf("git+https://%s@%s", r.Repo.Name, r.Repo.CommitSHA),
56+
Digest: map[string]string{
57+
"gitCommit": r.Repo.CommitSHA,
58+
},
59+
}
60+
61+
if opt == nil {
62+
opt = &AsInTotoResultOption{
63+
AsJSON2ResultOption{
64+
LogLevel: log.DefaultLevel,
65+
Details: false,
66+
Annotations: false,
67+
},
68+
}
69+
}
70+
71+
json2, err := r.resultsToJSON2(checkDocs, &opt.AsJSON2ResultOption)
72+
if err != nil {
73+
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
74+
}
75+
76+
out := statement{
77+
Statement: intoto.Statement{
78+
Type: intoto.StatementTypeUri,
79+
Subject: []*intoto.ResourceDescriptor{
80+
&subject,
81+
},
82+
PredicateType: InTotoPredicateType,
83+
},
84+
Predicate: InTotoPredicate{
85+
JSONScorecardResultV2: json2,
86+
Repo: nil,
87+
},
88+
}
89+
90+
encoder := json.NewEncoder(writer)
91+
if err := encoder.Encode(&out); err != nil {
92+
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("encoder.Encode: %v", err))
93+
}
94+
95+
return nil
96+
}

pkg/scorecard/statement_test.go

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright 2024 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package scorecard
16+
17+
import (
18+
"bytes"
19+
"encoding/json"
20+
"slices"
21+
"testing"
22+
"time"
23+
24+
"github.com/ossf/scorecard/v5/finding"
25+
)
26+
27+
func TestInToto(t *testing.T) {
28+
t.Parallel()
29+
// The intoto statement generation relies on the same generation as
30+
// the json output, so here we just check for correct assignments
31+
result := Result{
32+
Repo: RepoInfo{
33+
Name: "github.com/example/example",
34+
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
35+
},
36+
Scorecard: ScorecardInfo{
37+
Version: "1.2.3",
38+
CommitSHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
39+
},
40+
Date: time.Date(2024, time.February, 1, 13, 48, 0, 0, time.UTC),
41+
Findings: []finding.Finding{
42+
{
43+
Probe: "check for X",
44+
Outcome: finding.OutcomeTrue,
45+
Message: "found X",
46+
Location: &finding.Location{
47+
Path: "some/path/to/file",
48+
Type: finding.FileTypeText,
49+
},
50+
},
51+
{
52+
Probe: "check for Y",
53+
Outcome: finding.OutcomeFalse,
54+
Message: "did not find Y",
55+
},
56+
},
57+
}
58+
var w bytes.Buffer
59+
err := result.AsInToto(&w, jsonMockDocRead(), nil)
60+
if err != nil {
61+
t.Error("unexpected error: ", err)
62+
}
63+
64+
// Unmarshal the written json to a generic map
65+
stmt := statement{}
66+
if err := json.Unmarshal(w.Bytes(), &stmt); err != nil {
67+
t.Error("error unmarshaling statement", err)
68+
return
69+
}
70+
71+
// Check the data
72+
if len(stmt.Subject) != 1 {
73+
t.Error("unexpected statement subject length")
74+
}
75+
if stmt.Subject[0].GetDigest()["gitCommit"] != result.Repo.CommitSHA {
76+
t.Error("mismatched statement subject digest")
77+
}
78+
if stmt.Subject[0].GetName() != result.Repo.Name {
79+
t.Error("mismatched statement subject name")
80+
}
81+
82+
if stmt.PredicateType != InTotoPredicateType {
83+
t.Error("incorrect predicate type", stmt.PredicateType)
84+
}
85+
86+
// Check the predicate
87+
if stmt.Predicate.Scorecard.Commit != result.Scorecard.CommitSHA {
88+
t.Error("mismatch in scorecard commit")
89+
}
90+
if stmt.Predicate.Scorecard.Version != result.Scorecard.Version {
91+
t.Error("mismatch in scorecard version")
92+
}
93+
if stmt.Predicate.Repo != nil {
94+
t.Error("repo should be null")
95+
}
96+
if !slices.Equal(stmt.Predicate.Metadata, result.Metadata) {
97+
t.Error("mismatched metadata")
98+
}
99+
}

0 commit comments

Comments
 (0)