diff --git a/cmd/grant/cli/command/check.go b/cmd/grant/cli/command/check.go index 6a45528..7c97271 100644 --- a/cmd/grant/cli/command/check.go +++ b/cmd/grant/cli/command/check.go @@ -115,12 +115,14 @@ func runCheck(cfg *CheckConfig, userInput []string) (errs error) { } reportConfig := check.ReportConfig{ - Policy: policy, - Format: internal.Format(cfg.Output), - ShowPackages: cfg.ShowPackages, - CheckNonSPDX: cfg.CheckNonSPDX, - OsiApproved: cfg.OsiApproved, - Monitor: monitor, + Policy: policy, + Options: internal.ReportOptions{ + Format: internal.Format(cfg.Output), + ShowPackages: cfg.ShowPackages, + CheckNonSPDX: cfg.CheckNonSPDX, + OsiApproved: cfg.OsiApproved, + }, + Monitor: monitor, } rep, err := check.NewReport(reportConfig, userInput...) if err != nil { diff --git a/cmd/grant/cli/command/list.go b/cmd/grant/cli/command/list.go index 08de64c..dcc1858 100644 --- a/cmd/grant/cli/command/list.go +++ b/cmd/grant/cli/command/list.go @@ -8,10 +8,9 @@ import ( "github.com/anchore/clio" "github.com/anchore/grant/cmd/grant/cli/internal" - "github.com/anchore/grant/cmd/grant/cli/internal/check" + "github.com/anchore/grant/cmd/grant/cli/internal/list" "github.com/anchore/grant/cmd/grant/cli/option" "github.com/anchore/grant/event" - "github.com/anchore/grant/grant" "github.com/anchore/grant/internal/bus" "github.com/anchore/grant/internal/input" ) @@ -70,16 +69,17 @@ func runList(cfg *ListConfig, userInput []string) (errs error) { } }() - reportConfig := check.ReportConfig{ - Format: internal.Format(cfg.Output), - ShowPackages: cfg.ShowPackages, - CheckNonSPDX: cfg.CheckNonSPDX, - Policy: grant.DefaultPolicy(), - Monitor: monitor, + reportConfig := list.ReportConfig{ + Options: internal.ReportOptions{ + Format: internal.Format(cfg.Output), + ShowPackages: cfg.ShowPackages, + CheckNonSPDX: cfg.CheckNonSPDX, + }, + Monitor: monitor, } - rep, err := check.NewReport(reportConfig, userInput...) + rep, err := list.NewReport(reportConfig, userInput...) if err != nil { return err } - return rep.RenderList() + return rep.Render() } diff --git a/cmd/grant/cli/internal/check/report.go b/cmd/grant/cli/internal/check/report.go index 90f78b2..604c91e 100644 --- a/cmd/grant/cli/internal/check/report.go +++ b/cmd/grant/cli/internal/check/report.go @@ -3,6 +3,7 @@ package check import ( "encoding/json" "errors" + "fmt" "strings" "time" @@ -37,7 +38,7 @@ type ReportConfig struct { Monitor *event.ManualStagedProgress } -// NewReport will generate a new report for the given format. +// NewReport will generate a new report for the given format for the check command // The supplied policy is applied to all user requests. // If no policy is provided, the default policy will be used // If no requests are provided, an empty report will be generated @@ -49,7 +50,7 @@ func NewReport(rc ReportConfig, userRequests ...string) (*Report, error) { } rc.Options.Format = internal.ValidateFormat(rc.Options.Format) - cases := grant.NewCases(rc.Policy, userRequests...) + cases := grant.NewCases(userRequests...) ec := evalutation.EvaluationConfig{ Policy: rc.Policy, CheckNonSPDX: rc.Options.CheckNonSPDX, @@ -73,18 +74,10 @@ func (r *Report) Render() error { return r.renderCheckTree() case internal.JSON: return r.renderJSON() + default: + r.errors = append(r.errors, fmt.Errorf("invalid format: %s; valid formats are: %s", r.Config.Options.Format, internal.ValidFormats)) + return errors.Join(r.errors...) } - return errors.Join(r.errors...) -} - -func (r *Report) RenderList() error { - switch r.Config.Options.Format { - case internal.Table: - return r.renderList() - case internal.JSON: - return errors.New("json format not yet supported") - } - return errors.Join(r.errors...) } type Response struct { @@ -155,7 +148,7 @@ func (r *Report) renderCheckTree() error { uiLists = append(uiLists, resulList) resulList.AppendItem(color.Primary.Sprintf("%s", res.Case.UserInput)) - for _, rule := range res.Case.Policy.Rules { + for _, rule := range r.Config.Policy.Rules { failedEvaluations := r.Results.GetFailedEvaluations(res.Case.UserInput, rule) if len(failedEvaluations) == 0 { resulList.Indent() diff --git a/cmd/grant/cli/internal/format.go b/cmd/grant/cli/internal/format.go index 337fc90..96b3b3d 100644 --- a/cmd/grant/cli/internal/format.go +++ b/cmd/grant/cli/internal/format.go @@ -1,5 +1,7 @@ package internal +import "github.com/google/uuid" + type Format string const ( @@ -7,7 +9,9 @@ const ( Table Format = "table" ) -// validFormat returns a valid format or the default format if the given format is invalid +var ValidFormats = []Format{JSON, Table} + +// ValidateFormat returns a valid format or the default format if the given format is invalid func ValidateFormat(f Format) Format { switch f { case "json": @@ -18,3 +22,7 @@ func ValidateFormat(f Format) Format { return Table } } + +func NewReportID() string { + return uuid.Must(uuid.NewRandom()).String() +} diff --git a/cmd/grant/cli/internal/list/report.go b/cmd/grant/cli/internal/list/report.go index eb0563e..1fe7827 100644 --- a/cmd/grant/cli/internal/list/report.go +++ b/cmd/grant/cli/internal/list/report.go @@ -1,17 +1,20 @@ package list import ( + "encoding/json" + "errors" + "fmt" "time" "github.com/anchore/grant/cmd/grant/cli/internal" "github.com/anchore/grant/event" "github.com/anchore/grant/grant" - "github.com/anchore/grant/grant/evalutation" + "github.com/anchore/grant/internal/bus" ) type Report struct { ReportID string - Results evalutation.Results + Cases []grant.Case Config ReportConfig Timestamp string Monitor *event.ManualStagedProgress @@ -23,7 +26,7 @@ type ReportConfig struct { Monitor *event.ManualStagedProgress } -// NewReport will generate a new report for the given format. +// NewReport will generate a new report for the given format for the list command. // The supplied policy is applied to all user requests. // If no policy is provided, the default policy will be used // If no requests are provided, an empty report will be generated @@ -31,19 +34,80 @@ type ReportConfig struct { // Where do we render packages that had no licenses? func NewReport(rc ReportConfig, userRequests ...string) (*Report, error) { rc.Options.Format = internal.ValidateFormat(rc.Options.Format) - // TODO: we need a builder here that generates cases before the policy is applied cases := grant.NewCases(userRequests...) - ec := evalutation.EvaluationConfig{ - CheckNonSPDX: rc.Options.CheckNonSPDX, - OsiApproved: rc.Options.OsiApproved, - } - - results := evalutation.NewResults(ec, cases...) return &Report{ - Results: results, + ReportID: internal.NewReportID(), + Cases: cases, Config: rc, Timestamp: time.Now().Format(time.RFC3339), Monitor: rc.Monitor, }, nil } + +func (r *Report) Render() error { + switch r.Config.Options.Format { + case internal.Table: + return r.renderList() + case internal.JSON: + return r.renderJSON() + default: + r.errors = append(r.errors, fmt.Errorf("invalid format: %s; valid formats are: %s", r.Config.Options.Format, internal.ValidFormats)) + return errors.Join(r.errors...) + } +} + +type Response struct { + ReportID string `json:"report_id" yaml:"report_id"` + Timestamp string `json:"timestamp" yaml:"timestamp"` + Inputs []string `json:"inputs" yaml:"inputs"` + Results []Result `json:"results" yaml:"results"` +} + +type Result struct { + Input string `json:"input" yaml:"input"` + License string `json:"license" yaml:"license"` + Package string `json:"package" yaml:"package"` +} + +func NewResult(input, license, lp string) Result { + return Result{ + Input: input, + License: license, + Package: lp, + } +} + +func (r *Report) renderJSON() error { + resp := Response{ + ReportID: r.ReportID, + Timestamp: r.Timestamp, + Inputs: make([]string, 0), + Results: make([]Result, 0), + } + + for _, c := range r.Cases { + resp.Inputs = append(resp.Inputs, c.UserInput) + licenses := c.GetLicenses() + for _, l := range licenses { + resp.Results = append(resp.Results, NewResult(c.UserInput, l.Name, "")) + } + } + jsonData, err := json.Marshal(resp) + if err != nil { + return err + } + + bus.Report(string(jsonData)) + return nil +} + +func (r *Report) renderList() error { + _ = Response{ + ReportID: r.ReportID, + Timestamp: r.Timestamp, + Inputs: make([]string, 0), + Results: make([]Result, 0), + } + return nil +} diff --git a/go.mod b/go.mod index 8ceec2e..8ccb0b2 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/gobwas/glob v0.2.3 github.com/google/go-cmp v0.6.0 github.com/google/licenseclassifier/v2 v2.0.0 + github.com/google/uuid v1.4.0 github.com/gookit/color v1.5.4 github.com/hashicorp/go-multierror v1.1.1 github.com/jedib0t/go-pretty/v6 v6.4.9 @@ -96,7 +97,6 @@ require ( github.com/google/go-containerregistry v0.16.1 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect - github.com/google/uuid v1.4.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect diff --git a/grant/case.go b/grant/case.go index de3e5fe..544504c 100644 --- a/grant/case.go +++ b/grant/case.go @@ -26,10 +26,10 @@ import ( // Case is a collection of SBOMs and Licenses that are evaluated for a given UserInput type Case struct { - // SBOMS is a list of SBOMs that have licenses checked against the policy + // SBOMS is a list of SBOMs that were generated for the user input SBOMS []sbom.SBOM - // Licenses is a list of licenses that are checked against the policy + // Licenses is a list of licenses that were generated for the user input Licenses []License // UserInput is the string that was supplied by the user to build the case @@ -56,6 +56,28 @@ func NewCases(userInputs ...string) []Case { return cases } +func (c Case) GetLicenses() []License { + licenses := make([]License, 0) + for _, sb := range c.SBOMS { + for pkg := range sb.Artifacts.Packages.Enumerate() { + grantPkg := ConvertSyftPackage(pkg) + if len(grantPkg.Licenses) == 0 { + continue + } + + for _, l := range grantPkg.Licenses { + licenses = append(licenses, l) + } + } + } + + for _, l := range c.Licenses { + licenses = append(licenses, l) + } + + return licenses +} + type CaseHandler struct { Backend *backend.ClassifierBackend } @@ -179,7 +201,6 @@ func (ch *CaseHandler) handleFile(path string) (c Case, err error) { func (ch *CaseHandler) handleLicenseFile(path string) ([]License, error) { // alright we couldn't get an SBOM, let's see if the bytes are just a LICENSE (google license classifier) - // TODO: this is a little heavy, we might want to generate a backend and reuse it for all the files we're checking // google license classifier is noisy, so we'll silence it for now golog.SetOutput(io.Discard) diff --git a/grant/evalutation/license_evaluation_test.go b/grant/evalutation/license_evaluation_test.go index f63fe0f..08b60c2 100644 --- a/grant/evalutation/license_evaluation_test.go +++ b/grant/evalutation/license_evaluation_test.go @@ -23,7 +23,7 @@ func Test_NewLicenseEvaluations(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - grantCases := fixtureCase(tc.config, tc.caseFixture) + grantCases := fixtureCase(tc.caseFixture) for _, c := range grantCases { caseEvaluations := NewLicenseEvaluations(tc.config, c) if len(caseEvaluations) == 0 { @@ -40,6 +40,6 @@ func Test_NewLicenseEvaluations(t *testing.T) { } } -func fixtureCase(ec EvaluationConfig, fixturePath string) []grant.Case { - return grant.NewCases(ec.Policy, fixturePath) +func fixtureCase(fixturePath string) []grant.Case { + return grant.NewCases(fixturePath) } diff --git a/grant/evalutation/license_evalutation.go b/grant/evalutation/license_evalutation.go index 314198f..2504242 100644 --- a/grant/evalutation/license_evalutation.go +++ b/grant/evalutation/license_evalutation.go @@ -25,7 +25,7 @@ func checkSBOM(ec EvaluationConfig, sb sbom.SBOM) LicenseEvaluations { evaluations := make([]LicenseEvaluation, 0) for pkg := range sb.Artifacts.Packages.Enumerate() { // since we use syft as a library to generate the sbom we need to convert its packages/licenses to grant types - grantPkg := convertSyftPackage(pkg) + grantPkg := grant.ConvertSyftPackage(pkg) if len(grantPkg.Licenses) == 0 { // We need to include an evaluation that shows this package has no licenses le := NewLicenseEvaluation(grant.License{}, grantPkg, ec.Policy, []Reason{{ diff --git a/grant/evalutation/syft.go b/grant/evalutation/syft.go deleted file mode 100644 index 7df41e3..0000000 --- a/grant/evalutation/syft.go +++ /dev/null @@ -1,111 +0,0 @@ -package evalutation - -import ( - "strings" - - "github.com/github/go-spdx/v2/spdxexp" - - "github.com/anchore/grant/grant" - "github.com/anchore/grant/internal/log" - "github.com/anchore/grant/internal/spdxlicense" - syftPkg "github.com/anchore/syft/syft/pkg" -) - -func convertSyftPackage(p syftPkg.Package) *grant.Package { - locations := p.Locations.ToSlice() - packageLocations := make([]string, 0) - for _, location := range locations { - packageLocations = append(packageLocations, location.RealPath) - } - - return &grant.Package{ - Name: p.Name, - Version: p.Version, - Licenses: convertSyftLicenses(p.Licenses), - Locations: packageLocations, - } -} - -// convertSyftLicenses converts a syft LicenseSet to a grant License slice -// note: syft licenses can sometimes have complex SPDX expressions. -// Grant licenses break down these expressions into individual licenses. -// Because license expressions could potentially contain multiple licenses -// that are already represented in the syft license set we need to de-duplicate -// syft licenses have a "Value" field which is the name of the license -// given to an invalid SPDX expression; grant licenses store this field as "Name" -func convertSyftLicenses(set syftPkg.LicenseSet) (licenses []grant.License) { - licenses = make([]grant.License, 0) - checked := make(map[string]bool) - for _, license := range set.ToSlice() { - locations := license.Locations.ToSlice() - licenseLocations := make([]string, 0) - for _, location := range locations { - licenseLocations = append(licenseLocations, location.RealPath) - } - - if license.SPDXExpression != "" { - licenses = handleSPDXLicense(license, licenses, licenseLocations, checked) - continue - } - - licenses = addNonSPDXLicense(licenses, license, licenseLocations) - } - return licenses -} - -func handleSPDXLicense(license syftPkg.License, licenses []grant.License, licenseLocations []string, checked map[string]bool) []grant.License { - extractedLicenses, err := spdxexp.ExtractLicenses(license.SPDXExpression) - if err != nil { - // log.Errorf("unable to extract licenses from SPDX expression: %s", license.SPDXExpression) - return addNonSPDXLicense(licenses, license, licenseLocations) - } - - // process each extracted license from the SPDX expression - for _, extractedLicense := range extractedLicenses { - extractedLicense = strings.TrimRight(extractedLicense, "+") - // prevent duplicates from being added when using SPDX expressions - // EG: "MIT AND MIT" is valid, but we want to de-duplicate these - if check(checked, extractedLicense) { - continue - } - - // we have what seems to be a valid SPDX license ID, let's try and get more info about it - spdxLicense, err := spdxlicense.GetLicenseByID(extractedLicense) - if err != nil { - log.Errorf("unable to get license by ID: %s; no matching spdx id found", extractedLicense) - // if we can't find a matching SPDX license, just add the license as-is - // TODO: best matching against the spdx list index - addNonSPDXLicense(licenses, license, licenseLocations) - continue - } - - licenses = append(licenses, grant.License{ - SPDXExpression: extractedLicense, - Name: spdxLicense.Name, - Locations: licenseLocations, - Reference: spdxLicense.Reference, - IsDeprecatedLicenseID: spdxLicense.IsDeprecatedLicenseID, - DetailsURL: spdxLicense.DetailsURL, - ReferenceNumber: spdxLicense.ReferenceNumber, - LicenseID: spdxLicense.LicenseID, - SeeAlso: spdxLicense.SeeAlso, - IsOsiApproved: spdxLicense.IsOsiApproved, - }) - } - return licenses -} - -func addNonSPDXLicense(licenses []grant.License, license syftPkg.License, locations []string) []grant.License { - return append(licenses, grant.License{ - Name: license.Value, - Locations: locations, - }) -} - -func check(checked map[string]bool, license string) bool { - if _, ok := checked[license]; !ok { - checked[license] = true - return false - } - return true -} diff --git a/grant/license.go b/grant/license.go index f86cd70..bd857d5 100644 --- a/grant/license.go +++ b/grant/license.go @@ -1,5 +1,15 @@ package grant +import ( + "strings" + + "github.com/github/go-spdx/v2/spdxexp" + + "github.com/anchore/grant/internal/log" + "github.com/anchore/grant/internal/spdxlicense" + syftPkg "github.com/anchore/syft/syft/pkg" +) + type LicenseID string // License is a grant license. Either SPDXExpression or Name will be set. @@ -38,3 +48,87 @@ func (l License) String() string { func (l License) IsSPDX() bool { return l.SPDXExpression != "" } + +// ConvertSyftLicenses converts a syft LicenseSet to a grant License slice +// note: syft licenses can sometimes have complex SPDX expressions. +// Grant licenses break down these expressions into individual licenses. +// Because license expressions could potentially contain multiple licenses +// that are already represented in the syft license set we need to de-duplicate +// syft licenses have a "Value" field which is the name of the license +// given to an invalid SPDX expression; grant licenses store this field as "Name" +func ConvertSyftLicenses(set syftPkg.LicenseSet) (licenses []License) { + licenses = make([]License, 0) + checked := make(map[string]bool) + for _, license := range set.ToSlice() { + locations := license.Locations.ToSlice() + licenseLocations := make([]string, 0) + for _, location := range locations { + licenseLocations = append(licenseLocations, location.RealPath) + } + + if license.SPDXExpression != "" { + licenses = handleSPDXLicense(license, licenses, licenseLocations, checked) + continue + } + + licenses = addNonSPDXLicense(licenses, license, licenseLocations) + } + return licenses +} + +func handleSPDXLicense(license syftPkg.License, licenses []License, licenseLocations []string, checked map[string]bool) []License { + extractedLicenses, err := spdxexp.ExtractLicenses(license.SPDXExpression) + if err != nil { + // log.Errorf("unable to extract licenses from SPDX expression: %s", license.SPDXExpression) + return addNonSPDXLicense(licenses, license, licenseLocations) + } + + // process each extracted license from the SPDX expression + for _, extractedLicense := range extractedLicenses { + extractedLicense = strings.TrimRight(extractedLicense, "+") + // prevent duplicates from being added when using SPDX expressions + // EG: "MIT AND MIT" is valid, but we want to de-duplicate these + if check(checked, extractedLicense) { + continue + } + + // we have what seems to be a valid SPDX license ID, let's try and get more info about it + spdxLicense, err := spdxlicense.GetLicenseByID(extractedLicense) + if err != nil { + log.Errorf("unable to get license by ID: %s; no matching spdx id found", extractedLicense) + // if we can't find a matching SPDX license, just add the license as-is + // TODO: best matching against the spdx list index + addNonSPDXLicense(licenses, license, licenseLocations) + continue + } + + licenses = append(licenses, License{ + SPDXExpression: extractedLicense, + Name: spdxLicense.Name, + Locations: licenseLocations, + Reference: spdxLicense.Reference, + IsDeprecatedLicenseID: spdxLicense.IsDeprecatedLicenseID, + DetailsURL: spdxLicense.DetailsURL, + ReferenceNumber: spdxLicense.ReferenceNumber, + LicenseID: spdxLicense.LicenseID, + SeeAlso: spdxLicense.SeeAlso, + IsOsiApproved: spdxLicense.IsOsiApproved, + }) + } + return licenses +} + +func addNonSPDXLicense(licenses []License, license syftPkg.License, locations []string) []License { + return append(licenses, License{ + Name: license.Value, + Locations: locations, + }) +} + +func check(checked map[string]bool, license string) bool { + if _, ok := checked[license]; !ok { + checked[license] = true + return false + } + return true +} diff --git a/grant/package.go b/grant/package.go index 14db4a1..e2378b5 100644 --- a/grant/package.go +++ b/grant/package.go @@ -1,5 +1,7 @@ package grant +import syftPkg "github.com/anchore/syft/syft/pkg" + // PackageID is a unique identifier for a package that is tracked by grant // It's usually provided by the SBOM; It's calculated if an SBOM is generated type PackageID string @@ -12,3 +14,18 @@ type Package struct { Licenses []License `json:"licenses" yaml:"licenses"` Locations []string `json:"locations" yaml:"locations"` } + +func ConvertSyftPackage(p syftPkg.Package) *Package { + locations := p.Locations.ToSlice() + packageLocations := make([]string, 0) + for _, location := range locations { + packageLocations = append(packageLocations, location.RealPath) + } + + return &Package{ + Name: p.Name, + Version: p.Version, + Licenses: ConvertSyftLicenses(p.Licenses), + Locations: packageLocations, + } +}