scdiff: add basic compare functionality (#3363)

* Add unmarshall func.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* try to parse the details too.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* Compare skeleton.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* add basic comparison func.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* make normalize exported.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* split compare to separate func.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* Add experimental diff output.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* clarify expected format.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* Handle multiple repo results in files.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* add tests for compare.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* clean up result loading logic.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* add doc comments for advancescanners.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* clarify file error string.

Signed-off-by: Spencer Schrock <sschrock@google.com>

* add high level instructions for the command.

Signed-off-by: Spencer Schrock <sschrock@google.com>

---------

Signed-off-by: Spencer Schrock <sschrock@google.com>
This commit is contained in:
Spencer Schrock 2023-08-25 18:22:17 -07:00 committed by GitHub
parent 9a844abbba
commit b0a96fe9e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 638 additions and 4 deletions

View File

@ -0,0 +1,123 @@
// Copyright 2023 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 app
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/google/go-cmp/cmp"
"github.com/spf13/cobra"
"github.com/ossf/scorecard/v4/cmd/internal/scdiff/app/compare"
"github.com/ossf/scorecard/v4/cmd/internal/scdiff/app/format"
"github.com/ossf/scorecard/v4/pkg"
)
//nolint:gochecknoinits // common for cobra apps
func init() {
rootCmd.AddCommand(compareCmd)
}
var (
errMissingInputFiles = errors.New("must provide at least two files from scdiff generate")
errResultsDiffer = errors.New("results differ")
errNumResults = errors.New("number of results being compared differ")
compareCmd = &cobra.Command{
Use: "compare [flags] FILE1 FILE2",
Short: "Compare Scorecard results",
Long: `Compare Scorecard results`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return errMissingInputFiles
}
f1, err := os.Open(args[0])
if err != nil {
return fmt.Errorf("opening %q: %w", args[0], err)
}
defer f1.Close()
f2, err := os.Open(args[1])
if err != nil {
return fmt.Errorf("opening %q: %w", args[1], err)
}
defer f2.Close()
cmd.SilenceUsage = true // disables printing Usage
cmd.SilenceErrors = true // disables the "Error: <err>" message
return compareReaders(f1, f2, os.Stderr)
},
}
)
func compareReaders(x, y io.Reader, output io.Writer) error {
// results are currently newline delimited
xs := bufio.NewScanner(x)
ys := bufio.NewScanner(y)
for {
if shouldContinue, err := advanceScanners(xs, ys); err != nil {
return err
} else if !shouldContinue {
break
}
xResult, yResult, err := loadResults(xs, ys)
if err != nil {
return err
}
if !compare.Results(&xResult, &yResult) {
// go-cmp says its not production ready. Is this a valid usage?
// it certainly helps with readability.
fmt.Fprintf(output, "%s\n", cmp.Diff(xResult, yResult))
return errResultsDiffer
}
}
return nil
}
func loadResults(x, y *bufio.Scanner) (pkg.ScorecardResult, pkg.ScorecardResult, error) {
xResult, err := pkg.ExperimentalFromJSON2(strings.NewReader(x.Text()))
if err != nil {
return pkg.ScorecardResult{}, pkg.ScorecardResult{}, fmt.Errorf("parsing first result: %w", err)
}
yResult, err := pkg.ExperimentalFromJSON2(strings.NewReader(y.Text()))
if err != nil {
return pkg.ScorecardResult{}, pkg.ScorecardResult{}, fmt.Errorf("parsing second result: %w", err)
}
format.Normalize(&xResult)
format.Normalize(&yResult)
return xResult, yResult, nil
}
// advanceScanners is intended to expand the normal `for scanner.Scan()` semantics to two scanners,
// it keeps the scanners in sync, and determines if iteration should continue.
//
// Iteration should continue until any scanner reaches EOF, or any scanner encounters a non-EOF error.
func advanceScanners(x, y *bufio.Scanner) (shouldContinue bool, err error) {
xContinue := x.Scan()
yContinue := y.Scan()
if err := x.Err(); err != nil {
return false, fmt.Errorf("reading results: %w", err)
}
if err := y.Err(); err != nil {
return false, fmt.Errorf("reading results: %w", err)
}
if xContinue != yContinue {
return false, errNumResults
}
return xContinue, nil
}

View File

@ -0,0 +1,70 @@
// Copyright 2023 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 compare
import "github.com/ossf/scorecard/v4/pkg"
// results should be normalized before comparison.
func Results(r1, r2 *pkg.ScorecardResult) bool {
if r1 == nil && r2 == nil {
return true
}
if (r1 != nil) != (r2 != nil) {
return false
}
// intentionally not comparing CommitSHA
if r1.Repo.Name != r2.Repo.Name {
return false
}
if !compareChecks(r1, r2) {
return false
}
// not comparing findings, as we're JSON first for now
return true
}
func compareChecks(r1, r2 *pkg.ScorecardResult) bool {
if len(r1.Checks) != len(r2.Checks) {
return false
}
for i := 0; i < len(r1.Checks); i++ {
if r1.Checks[i].Name != r2.Checks[i].Name {
return false
}
if r1.Checks[i].Score != r2.Checks[i].Score {
return false
}
if r1.Checks[i].Reason != r2.Checks[i].Reason {
return false
}
if len(r1.Checks[i].Details) != len(r2.Checks[i].Details) {
return false
}
for j := 0; j < len(r1.Checks[i].Details); j++ {
if r1.Checks[i].Details[j].Type != r2.Checks[i].Details[j].Type {
return false
}
// TODO compare detail specifics?
}
}
return true
}

View File

@ -0,0 +1,222 @@
// Copyright 2023 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 compare
import (
"testing"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/pkg"
)
func TestResults(t *testing.T) {
//nolint:govet // field alignment
tests := []struct {
name string
a, b *pkg.ScorecardResult
wantEqual bool
}{
{
name: "both nil",
a: nil,
b: nil,
wantEqual: true,
},
{
name: "one nil",
a: nil,
b: &pkg.ScorecardResult{},
wantEqual: false,
},
{
name: "different repo name",
a: &pkg.ScorecardResult{
Repo: pkg.RepoInfo{
Name: "a",
},
},
b: &pkg.ScorecardResult{
Repo: pkg.RepoInfo{
Name: "b",
},
},
wantEqual: false,
},
{
name: "unequal amount of checks",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Name: "a1",
},
{
Name: "a2",
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Name: "b",
},
},
},
wantEqual: false,
},
{
name: "different check name",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Name: "a",
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Name: "b",
},
},
},
wantEqual: false,
},
{
name: "different check score",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Score: 1,
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Score: 2,
},
},
},
wantEqual: false,
},
{
name: "different check reason",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Reason: "a",
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Reason: "b",
},
},
},
wantEqual: false,
},
{
name: "unequal number of details",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Details: []checker.CheckDetail{},
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Details: []checker.CheckDetail{
{
Type: checker.DetailWarn,
},
},
},
},
},
wantEqual: false,
},
{
name: "details have differnet levels",
a: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Details: []checker.CheckDetail{
{
Type: checker.DetailInfo,
},
},
},
},
},
b: &pkg.ScorecardResult{
Checks: []checker.CheckResult{
{
Details: []checker.CheckDetail{
{
Type: checker.DetailWarn,
},
},
},
},
},
wantEqual: false,
},
{
name: "equal results",
a: &pkg.ScorecardResult{
Repo: pkg.RepoInfo{
Name: "foo",
},
Checks: []checker.CheckResult{
{
Name: "bar",
Details: []checker.CheckDetail{
{
Type: checker.DetailWarn,
},
},
},
},
},
b: &pkg.ScorecardResult{
Repo: pkg.RepoInfo{
Name: "foo",
},
Checks: []checker.CheckResult{
{
Name: "bar",
Details: []checker.CheckDetail{
{
Type: checker.DetailWarn,
},
},
},
},
},
wantEqual: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Results(tt.a, tt.b); got != tt.wantEqual {
t.Errorf("Results() = %v, want %v", got, tt.wantEqual)
}
})
}
}

View File

@ -0,0 +1,136 @@
// Copyright 2023 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 app
import (
"io"
"os"
"strings"
"testing"
)
//nolint:lll // results are long
func Test_compare(t *testing.T) {
t.Parallel()
//nolint:govet // struct alignment
tests := []struct {
name string
x string
y string
match bool
wantErrSubstr string
}{
{
name: "results match",
x: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
y: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
match: true,
},
{
name: "unequal number of results",
x: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
y: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
wantErrSubstr: "number of results",
},
{
name: "results differ",
x: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
y: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":7.0,"checks":[{"details":null,"score":7,"reason":"3 existing vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
wantErrSubstr: "results differ",
},
{
name: "x results fail to parse",
x: `not a scorecard result
`,
y: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":7.0,"checks":[{"details":null,"score":7,"reason":"3 existing vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
wantErrSubstr: "parsing first",
},
{
name: "y results fail to parse",
x: `{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":7.0,"checks":[{"details":null,"score":7,"reason":"3 existing vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`,
y: `not a scorecard result
`,
wantErrSubstr: "parsing second",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
x := strings.NewReader(tt.x)
y := strings.NewReader(tt.y)
err := compareReaders(x, y, os.Stderr)
if (err != nil) == tt.match {
t.Errorf("wanted match: %t, but got err: %v", tt.match, err)
}
if !tt.match && !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Errorf("wanted err: %v, got err: %v", tt.wantErrSubstr, err)
}
})
}
}
type alwaysErrorReader struct{}
func (a alwaysErrorReader) Read(b []byte) (n int, err error) {
return 0, io.ErrClosedPipe
}
//nolint:lll // results are long
func Test_compare_reader_err(t *testing.T) {
t.Parallel()
//nolint:govet // struct alignment
tests := []struct {
name string
x io.Reader
y io.Reader
}{
{
name: "error in y reader",
x: strings.NewReader(`{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`),
y: alwaysErrorReader{},
},
{
name: "error in x reader",
x: alwaysErrorReader{},
y: strings.NewReader(`{"date":"2023-08-11T10:22:43-07:00","repo":{"name":"github.com/foo/bar","commit":"f0840f7158c8044af2bd9b8aa661d7942b1f29d2"},"scorecard":{"version":"","commit":"unknown"},"score":10.0,"checks":[{"details":null,"score":10,"reason":"no vulnerabilities detected","name":"Vulnerabilities","documentation":{"url":"https://github.com/ossf/scorecard/blob/main/docs/checks.md#vulnerabilities","short":"Determines if the project has open, known unfixed vulnerabilities."}}],"metadata":null}
`),
},
{
name: "error in both readesr",
x: alwaysErrorReader{},
y: alwaysErrorReader{},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := compareReaders(tt.x, tt.y, os.Stderr); err == nil { // if NO error
t.Errorf("wanted error, got none")
}
})
}
}

View File

@ -26,7 +26,7 @@ import (
const logLevel = log.DefaultLevel
func normalize(r *pkg.ScorecardResult) {
func Normalize(r *pkg.ScorecardResult) {
if r == nil {
return
}
@ -55,6 +55,6 @@ func JSON(r *pkg.ScorecardResult, w io.Writer) error {
if err != nil {
return err
}
normalize(r)
Normalize(r)
return r.AsJSON2(details, logLevel, docs, w)
}

View File

@ -167,8 +167,8 @@ func TestJSON(t *testing.T) {
func Test_normalize_nil_safe(t *testing.T) {
var x, y *pkg.ScorecardResult
normalize(x)
normalize(y)
Normalize(x)
Normalize(y)
if !cmp.Equal(x, y) {
t.Errorf("normalized results differ: %v", cmp.Diff(x, y))
}

View File

@ -12,6 +12,20 @@
// See the License for the specific language governing permissions and
// limitations under the License.
/*
Scdiff is a tool to create and diff goldens when analyzing results.
These results can be from different points in time, or generated by differnet versions of Scorecard.
Intended usage:
1. Use scdiff generate on a list of repositories to produce the golden file.
2. Similarly, evaluate the same list using the proposed changed.
3. Run scdiff compare to compare the two files.
4. If changes are intentional and acceptable, run scdiff accept to merge the changes to the golden.
*/
package main
import "github.com/ossf/scorecard/v4/cmd/internal/scdiff/app"

View File

@ -110,3 +110,28 @@ func typeToString(cd checker.DetailType) string {
return "Debug"
}
}
func stringToDetailType(s string) checker.DetailType {
switch s {
case "Debug":
return checker.DetailDebug
case "Info":
return checker.DetailInfo
case "Warn":
return checker.DetailWarn
}
return checker.DetailType(-1) // Uhhh todo err, at least for now return an unofficial DetailUnknown
}
func stringToDetail(s string) checker.CheckDetail {
parts := strings.SplitN(s, ":", 3)
if len(parts) < 2 {
return checker.CheckDetail{} // err?
}
return checker.CheckDetail{
Type: stringToDetailType(parts[0]),
Msg: checker.LogMessage{
Text: parts[1],
},
}
}

View File

@ -20,6 +20,7 @@ import (
"io"
"time"
"github.com/ossf/scorecard/v4/checker"
docs "github.com/ossf/scorecard/v4/docs/checks"
sce "github.com/ossf/scorecard/v4/errors"
"github.com/ossf/scorecard/v4/log"
@ -175,6 +176,49 @@ func (r *ScorecardResult) AsJSON2(showDetails bool,
return nil
}
// This function is experimental. Do not depend on it, it may be removed at any point.
func ExperimentalFromJSON2(r io.Reader) (ScorecardResult, error) {
var jsr JSONScorecardResultV2
decoder := json.NewDecoder(r)
if err := decoder.Decode(&jsr); err != nil {
return ScorecardResult{}, fmt.Errorf("decode json: %w", err)
}
date, err := time.Parse(time.RFC3339, jsr.Date)
if err != nil {
return ScorecardResult{}, fmt.Errorf("parse scorecard analysis time: %w", err)
}
sr := ScorecardResult{
Repo: RepoInfo{
Name: jsr.Repo.Name,
CommitSHA: jsr.Repo.Commit,
},
Scorecard: ScorecardInfo{
Version: jsr.Scorecard.Version,
CommitSHA: jsr.Scorecard.Commit,
},
Date: date,
Metadata: jsr.Metadata,
Checks: make([]checker.CheckResult, 0, len(jsr.Checks)),
}
for _, check := range jsr.Checks {
cr := checker.CheckResult{
Name: check.Name,
Score: check.Score,
Reason: check.Reason,
}
cr.Details = make([]checker.CheckDetail, 0, len(check.Details))
for _, detail := range check.Details {
cr.Details = append(cr.Details, stringToDetail(detail))
}
sr.Checks = append(sr.Checks, cr)
}
return sr, nil
}
func (r *ScorecardResult) AsFJSON(showDetails bool,
logLevel log.Level, checkDocs docs.Doc, writer io.Writer,
) error {