Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Support results output as in-toto statement #4491

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ require (
github.com/gobwas/glob v0.2.3
github.com/google/go-github/v53 v53.2.0
github.com/google/osv-scanner v1.9.2
github.com/in-toto/attestation v1.1.0
github.com/mcuadros/go-jsonschema-generator v0.0.0-20200330054847-ba7a369d4303
github.com/onsi/ginkgo/v2 v2.22.2
github.com/otiai10/copy v1.14.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd/go.mod h1:
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/in-toto/attestation v1.1.0 h1:oRWzfmZPDSctChD0VaQV7MJrywKOzyNrtpENQFq//2Q=
github.com/in-toto/attestation v1.1.0/go.mod h1:DB59ytd3z7cIHgXxwpSX2SABrU6WJUKg/grpdgHVgVs=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
Expand Down
1 change: 1 addition & 0 deletions options/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func (o *Options) AddFlags(cmd *cobra.Command) {
FormatDefault,
FormatJSON,
FormatProbe,
FormatStatement,
}

if o.isSarifEnabled() {
Expand Down
4 changes: 3 additions & 1 deletion options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const (
FormatDefault = "default"
// FormatRaw specifies that results should be output in raw format.
FormatRaw = "raw"
// FormatStatement specifies that results should be output in an in-toto statement.
FormatStatement = "statement"
Comment on lines +91 to +92
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion: any preference on statement vs intoto ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also considered attestation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think intoto would make it crystal clear what the output format would be.


// Environment variables.
// EnvVarEnableSarif is the environment variable which controls enabling
Expand Down Expand Up @@ -246,7 +248,7 @@ func (o *Options) isV6Enabled() bool {

func validateFormat(format string) bool {
switch format {
case FormatJSON, FormatProbe, FormatSarif, FormatDefault, FormatRaw:
case FormatJSON, FormatProbe, FormatSarif, FormatDefault, FormatRaw, FormatStatement:
return true
default:
return false
Expand Down
21 changes: 15 additions & 6 deletions pkg/scorecard/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,20 @@
return nil
}

// AsJSON2 exports results as JSON for new detail format.
func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2ResultOption) error {
func (r *Result) resultsToJSON2(checkDocs docs.Doc, opt *AsJSON2ResultOption) (JSONScorecardResultV2, error) {
if opt == nil {
opt = &AsJSON2ResultOption{
LogLevel: log.DefaultLevel,
Details: false,
Annotations: false,
}
}

score, err := r.GetAggregateScore(checkDocs)
if err != nil {
return err
return JSONScorecardResultV2{}, err
}

encoder := json.NewEncoder(writer)
out := JSONScorecardResultV2{
Repo: jsonRepoV2{
Name: r.Repo.Name,
Expand All @@ -160,10 +159,10 @@
for _, checkResult := range r.Checks {
doc, e := checkDocs.GetCheck(checkResult.Name)
if e != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", checkResult.Name, e))
return out, fmt.Errorf("GetCheck: %s: %w", checkResult.Name, e)

Check warning on line 162 in pkg/scorecard/json.go

View check run for this annotation

Codecov / codecov/patch

pkg/scorecard/json.go#L162

Added line #L162 was not covered by tests
}
if doc == nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("GetCheck: %s: %v", checkResult.Name, errNoDoc))
return out, fmt.Errorf("GetCheck: %s: %w", checkResult.Name, errNoDoc)

Check warning on line 165 in pkg/scorecard/json.go

View check run for this annotation

Codecov / codecov/patch

pkg/scorecard/json.go#L165

Added line #L165 was not covered by tests
spencerschrock marked this conversation as resolved.
Show resolved Hide resolved
}

tmpResult := jsonCheckResultV2{
Expand All @@ -190,6 +189,16 @@
}
out.Checks = append(out.Checks, tmpResult)
}
return out, nil
}

// AsJSON2 exports results as JSON for new detail format.
func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2ResultOption) error {
encoder := json.NewEncoder(writer)
out, err := r.resultsToJSON2(checkDocs, opt)
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}

if err := encoder.Encode(out); err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("encoder.Encode: %v", err))
Expand Down
9 changes: 9 additions & 0 deletions pkg/scorecard/scorecard_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@
LogLevel: log.ParseLevel(opts.LogLevel),
}
err = results.AsJSON2(output, doc, o)
case options.FormatStatement:
o := &AsStatementResultOption{
AsJSON2ResultOption: AsJSON2ResultOption{
Details: opts.ShowDetails,
Annotations: opts.ShowAnnotations,
LogLevel: log.ParseLevel(opts.LogLevel),
},
}
err = results.AsStatement(output, doc, o)

Check warning on line 167 in pkg/scorecard/scorecard_result.go

View check run for this annotation

Codecov / codecov/patch

pkg/scorecard/scorecard_result.go#L159-L167

Added lines #L159 - L167 were not covered by tests
case options.FormatProbe:
var opts *ProbeResultOption
err = results.AsProbe(output, opts)
Expand Down
95 changes: 95 additions & 0 deletions pkg/scorecard/statement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright 2025 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package scorecard

import (
"encoding/json"
"fmt"
"io"

intoto "github.com/in-toto/attestation/go/v1"

docs "github.com/ossf/scorecard/v5/docs/checks"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/log"
)

const (
PredicateType = "https://scorecard.dev/result/v0.1"
)

type Statement struct {
intoto.Statement
Predicate Predicate `json:"predicate"`
}

// Predicate overrides JSONScorecardResultV2 with a nullable Repo field.
type Predicate struct {
Repo *jsonRepoV2 `json:"repo,omitempty"`
JSONScorecardResultV2
}

// AsStatementResultOption wraps AsJSON2ResultOption preparing it for.
type AsStatementResultOption struct {
AsJSON2ResultOption
}

// AsStatement converts the results as an in-toto statement.
func (r *Result) AsStatement(writer io.Writer, checkDocs docs.Doc, opt *AsStatementResultOption) error {
// Build the attestation subject from the result Repo.
subject := intoto.ResourceDescriptor{
Name: r.Repo.Name,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this set the URI field by any chance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, it now populates the URi with an spdx-style downlaod locator (check the updated example above)

Uri: fmt.Sprintf("git+https://%s@%s", r.Repo.Name, r.Repo.CommitSHA),
Digest: map[string]string{
"gitCommit": r.Repo.CommitSHA,
},
}

if opt == nil {
opt = &AsStatementResultOption{
AsJSON2ResultOption{
LogLevel: log.DefaultLevel,
Details: false,
Annotations: false,
},
}
}

json2, err := r.resultsToJSON2(checkDocs, &opt.AsJSON2ResultOption)
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}

Check warning on line 73 in pkg/scorecard/statement.go

View check run for this annotation

Codecov / codecov/patch

pkg/scorecard/statement.go#L72-L73

Added lines #L72 - L73 were not covered by tests

out := Statement{
Statement: intoto.Statement{
Type: intoto.StatementTypeUri,
Subject: []*intoto.ResourceDescriptor{
&subject,
},
PredicateType: PredicateType,
},
Predicate: Predicate{
JSONScorecardResultV2: json2,
Repo: nil,
},
}

encoder := json.NewEncoder(writer)
if err := encoder.Encode(&out); err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("encoder.Encode: %v", err))
}

Check warning on line 92 in pkg/scorecard/statement.go

View check run for this annotation

Codecov / codecov/patch

pkg/scorecard/statement.go#L91-L92

Added lines #L91 - L92 were not covered by tests

return nil
}
99 changes: 99 additions & 0 deletions pkg/scorecard/statement_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2024 OpenSSF Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package scorecard

import (
"bytes"
"encoding/json"
"slices"
"testing"
"time"

"github.com/ossf/scorecard/v5/finding"
)

func TestStatement(t *testing.T) {
t.Parallel()
// The intoto statement generation relies on the same generation as
// the json output, so here we just check for correct assignments
result := Result{
Repo: RepoInfo{
Name: "github.com/example/example",
CommitSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
Scorecard: ScorecardInfo{
Version: "1.2.3",
CommitSHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
Date: time.Date(2024, time.February, 1, 13, 48, 0, 0, time.UTC),
Findings: []finding.Finding{
{
Probe: "check for X",
Outcome: finding.OutcomeTrue,
Message: "found X",
Location: &finding.Location{
Path: "some/path/to/file",
Type: finding.FileTypeText,
},
},
{
Probe: "check for Y",
Outcome: finding.OutcomeFalse,
Message: "did not find Y",
},
},
}
var w bytes.Buffer
err := result.AsStatement(&w, jsonMockDocRead(), nil)
if err != nil {
t.Error("unexpected error: ", err)
}

// Unmarshal the written json to a generic map
stmt := Statement{}
if err := json.Unmarshal(w.Bytes(), &stmt); err != nil {
t.Error("error unmarshaling statement", err)
return
}

// Check the data
if len(stmt.Subject) != 1 {
t.Error("unexpected statement subject length")
}
if stmt.Subject[0].GetDigest()["gitCommit"] != result.Repo.CommitSHA {
t.Error("mismatched statement subject digest")
}
if stmt.Subject[0].GetName() != result.Repo.Name {
t.Error("mismatched statement subject name")
}

if stmt.PredicateType != PredicateType {
t.Error("incorrect predicate type", stmt.PredicateType)
}

// Check the predicate
if stmt.Predicate.Scorecard.Commit != result.Scorecard.CommitSHA {
t.Error("mismatch in scorecard commit")
}
if stmt.Predicate.Scorecard.Version != result.Scorecard.Version {
t.Error("mismatch in scorecard version")
}
if stmt.Predicate.Repo != nil {
t.Error("repo should be null")
}
if !slices.Equal(stmt.Predicate.Metadata, result.Metadata) {
t.Error("mismatched metadata")
}
}
Loading