diff --git a/go.mod b/go.mod index 4ce6932ffd4..bfb46555411 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a02805cc888..610e96dd74b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/options/flags.go b/options/flags.go index 0545b0293fc..eed6e0c2cc1 100644 --- a/options/flags.go +++ b/options/flags.go @@ -192,6 +192,7 @@ func (o *Options) AddFlags(cmd *cobra.Command) { FormatDefault, FormatJSON, FormatProbe, + FormatStatement, } if o.isSarifEnabled() { diff --git a/options/options.go b/options/options.go index ef3077a9d95..0f4a39578f4 100644 --- a/options/options.go +++ b/options/options.go @@ -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" // Environment variables. // EnvVarEnableSarif is the environment variable which controls enabling @@ -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 diff --git a/pkg/scorecard/json.go b/pkg/scorecard/json.go index baa9388ab63..814724a8f36 100644 --- a/pkg/scorecard/json.go +++ b/pkg/scorecard/json.go @@ -128,8 +128,7 @@ func (r *Result) AsJSON(showDetails bool, logLevel log.Level, writer io.Writer) 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, @@ -137,12 +136,12 @@ func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2Resul 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, @@ -160,10 +159,10 @@ func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2Resul 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) } 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) } tmpResult := jsonCheckResultV2{ @@ -190,6 +189,16 @@ func (r *Result) AsJSON2(writer io.Writer, checkDocs docs.Doc, opt *AsJSON2Resul } 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)) diff --git a/pkg/scorecard/scorecard_result.go b/pkg/scorecard/scorecard_result.go index 6f925092135..595a6bd3fda 100644 --- a/pkg/scorecard/scorecard_result.go +++ b/pkg/scorecard/scorecard_result.go @@ -156,6 +156,15 @@ func FormatResults( 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) case options.FormatProbe: var opts *ProbeResultOption err = results.AsProbe(output, opts) diff --git a/pkg/scorecard/statement.go b/pkg/scorecard/statement.go new file mode 100644 index 00000000000..809fc7ae8f7 --- /dev/null +++ b/pkg/scorecard/statement.go @@ -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, + 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()) + } + + 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)) + } + + return nil +} diff --git a/pkg/scorecard/statement_test.go b/pkg/scorecard/statement_test.go new file mode 100644 index 00000000000..24f94ab4ebb --- /dev/null +++ b/pkg/scorecard/statement_test.go @@ -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") + } +}