diff --git a/cmd/state/internal/cmdtree/cmdtree.go b/cmd/state/internal/cmdtree/cmdtree.go index 85bc49e4c1..67016bb3d3 100644 --- a/cmd/state/internal/cmdtree/cmdtree.go +++ b/cmd/state/internal/cmdtree/cmdtree.go @@ -26,6 +26,11 @@ func New(prime *primer.Values, args ...string) *CmdTree { newLogoutCommand(prime), ) + cveCmd := newCveCommand(prime) + cveCmd.AddChildren( + newReportCommand(prime), + ) + exportCmd := newExportCommand(prime) exportCmd.AddChildren( newRecipeCommand(prime), @@ -115,7 +120,7 @@ func New(prime *primer.Values, args ...string) *CmdTree { newActivateCommand(prime), newInitCommand(prime), newPushCommand(prime), - newCveCommand(prime), + cveCmd, projectsCmd, authCmd, exportCmd, diff --git a/cmd/state/internal/cmdtree/cve.go b/cmd/state/internal/cmdtree/cve.go index 4a041ae7fa..896f7f3c8a 100644 --- a/cmd/state/internal/cmdtree/cve.go +++ b/cmd/state/internal/cmdtree/cve.go @@ -5,6 +5,7 @@ import ( "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/primer" "github.com/ActiveState/cli/internal/runners/cve" + "github.com/ActiveState/cli/pkg/project" ) func newCveCommand(prime *primer.Values) *captain.Command { @@ -12,8 +13,8 @@ func newCveCommand(prime *primer.Values) *captain.Command { cmd := captain.NewCommand( "cve", - locale.Tl("cve_title", "CVE Summary"), - locale.Tl("cve_description", "Show a summary of CVE vulnerabilities"), + locale.Tl("cve_title", "Vulnerability Summary"), + locale.Tl("cve_description", "Show a summary of project vulnerabilities"), prime.Output(), prime.Config(), []*captain.Flag{}, @@ -25,3 +26,29 @@ func newCveCommand(prime *primer.Values) *captain.Command { cmd.SetGroup(PlatformGroup) return cmd } + +func newReportCommand(prime *primer.Values) *captain.Command { + report := cve.NewReport(prime) + params := cve.ReportParams{ + Namespace: &project.Namespaced{}, + } + + return captain.NewCommand( + "report", + locale.Tl("cve_report_title", "Vulnerability Report"), + locale.Tl("cve_report_cmd_description", "Show a detailed report of project vulnerabilities"), + prime.Output(), + prime.Config(), + []*captain.Flag{}, + []*captain.Argument{ + { + Name: locale.Tl("cve_report_namespace_arg", "Organization/Project"), + Description: locale.Tl("cve_report_namespace_arg_description", "The project for which the report is created"), + Value: params.Namespace, + }, + }, + func(_ *captain.Command, _ []string) error { + return report.Run(¶ms) + }, + ) +} diff --git a/internal/runners/cve/cve.go b/internal/runners/cve/cve.go index 362a8a28c4..73075e482d 100644 --- a/internal/runners/cve/cve.go +++ b/internal/runners/cve/cve.go @@ -59,8 +59,8 @@ func (c *Cve) Run() error { if !c.auth.Authenticated() { return errs.AddTips( - locale.NewError("cve_needs_authentication", "You need to be authenticated in order to access vulnerability information about your project."), - locale.Tl("auth_tip", "Run `state auth` to authenticate."), + locale.NewError("cve_needs_authentication"), + locale.T("auth_tip"), ) } @@ -69,33 +69,12 @@ func (c *Cve) Run() error { return locale.WrapError(err, "cve_mediator_resp", "Failed to retrieve vulnerability information") } - var packageVulnerabilities []ByPackageOutput - visited := make(map[string]struct{}) - for _, v := range resp.Project.Commit.Ingredients { - if len(v.Vulnerabilities) == 0 { - continue - } - - // Remove this block with story https://www.pivotaltracker.com/story/show/176508772 - // filter double entries - if _, ok := visited[v.Name]; ok { - continue - } - visited[v.Name] = struct{}{} - - countByVersion := make(map[string]int) - for _, ve := range v.Vulnerabilities { - if _, ok := countByVersion[ve.Version]; !ok { - countByVersion[ve.Version] = 0 - } - countByVersion[ve.Version]++ - } - - for ver, count := range countByVersion { - packageVulnerabilities = append(packageVulnerabilities, ByPackageOutput{ - v.Name, ver, count, - }) - } + details := model.ExtractPackageVulnerabilities(resp.Project.Commit.Ingredients) + packageVulnerabilities := make([]ByPackageOutput, 0, len(details)) + for _, v := range details { + packageVulnerabilities = append(packageVulnerabilities, ByPackageOutput{ + v.Name, v.Version, len(v.Details), + }) } cveOutput := &outputData{ @@ -120,6 +99,14 @@ type SeverityCountOutput struct { Severity string `locale:"severity,Severity"` } +func (od *outputDataPrinter) printFooter() { + od.output.Print("") + od.output.Print([]string{ + locale.Tl("cve_hint_report", "To view a detailed report for this runtime, run [ACTIONABLE]state cve report[/RESET]"), + locale.Tl("cve_hint_specific_report", "For a specific runtime, run [ACTIONABLE]state cve report [Organization/Project][/RESET]"), + }) +} + func (od *outputDataPrinter) MarshalOutput(format output.Format) interface{} { if format != output.PlainFormatName { return od.data @@ -135,6 +122,7 @@ func (od *outputDataPrinter) MarshalOutput(format output.Format) interface{} { if len(od.data.Histogram) == 0 { od.output.Print("") od.output.Print(fmt.Sprintf("[SUCCESS]✔ %s[/RESET]", locale.Tl("no_cves", "No CVEs detected!"))) + od.printFooter() return output.Suppress } @@ -162,5 +150,6 @@ func (od *outputDataPrinter) MarshalOutput(format output.Format) interface{} { od.output.Print(output.Heading(fmt.Sprintf("%d Affected Packages", len(od.data.Packages)))) od.output.Print(od.data.Packages) + od.printFooter() return output.Suppress } diff --git a/internal/runners/cve/report.go b/internal/runners/cve/report.go new file mode 100644 index 0000000000..bb07bae4f9 --- /dev/null +++ b/internal/runners/cve/report.go @@ -0,0 +1,174 @@ +package cve + +import ( + "fmt" + "sort" + "strconv" + "time" + + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/locale" + "github.com/ActiveState/cli/internal/logging" + "github.com/ActiveState/cli/internal/output" + medmodel "github.com/ActiveState/cli/pkg/platform/api/mediator/model" + "github.com/ActiveState/cli/pkg/platform/authentication" + "github.com/ActiveState/cli/pkg/platform/model" + "github.com/ActiveState/cli/pkg/project" +) + +type Report struct { + proj *project.Project + auth *authentication.Auth + out output.Outputer +} + +type ReportInfo struct { + Project string `locale:"project,Project"` + CommitID string `locale:"commit_id,Commit ID"` + Date string `locale:"generated_on,Generated on"` +} + +func NewReport(prime primeable) *Report { + return &Report{prime.Project(), prime.Auth(), prime.Output()} +} + +type ReportParams struct { + Namespace *project.Namespaced +} + +type reportData struct { + Project string `json:"project"` + CommitID string `json:"commitID"` + Date time.Time `json:"generated_on"` + Histogram []medmodel.SeverityCount `json:"vulnerability_histogram"` + Packages []model.PackageVulnerability `json:"packages"` +} + +type reportDataPrinter struct { + output output.Outputer + data *reportData +} + +func (r *Report) Run(params *ReportParams) error { + ns := params.Namespace + if !ns.IsValid() { + if r.proj == nil { + return locale.NewInputError("err_no_project") + } + ns = r.proj.Namespace() + } + + if !r.auth.Authenticated() { + return errs.AddTips( + locale.NewError("cve_needs_authentication"), + locale.T("auth_tip"), + ) + } + + logging.Debug("Fetching vulnerabilities for %s", ns.String()) + resp, err := model.FetchProjectVulnerabilities(r.auth, ns.Owner, ns.Project) + if err != nil { + return locale.WrapError(err, "cve_mediator_resp", "Failed to retrieve vulnerability information") + } + + packageVulnerabilities := model.ExtractPackageVulnerabilities(resp.Project.Commit.Ingredients) + + reportOutput := &reportData{ + Project: ns.String(), + CommitID: resp.Project.Commit.CommitID, + Date: time.Now(), + + Histogram: resp.Project.Commit.VulnerabilityHistogram, + Packages: packageVulnerabilities, + } + + rdp := &reportDataPrinter{ + r.out, + reportOutput, + } + + r.out.Print(rdp) + + return nil +} + +func (rd *reportDataPrinter) MarshalOutput(format output.Format) interface{} { + if format != output.PlainFormatName { + return rd.data + } + ri := &ReportInfo{ + fmt.Sprintf("[ACTIONABLE]%s[/RESET]", rd.data.Project), + rd.data.CommitID, + rd.data.Date.Format("01/02/06"), + } + rd.output.Print(struct { + *ReportInfo `opts:"verticalTable"` + }{ri}) + + if len(rd.data.Histogram) == 0 { + rd.output.Print("") + rd.output.Print(fmt.Sprintf("[SUCCESS]✔ %s[/RESET]", locale.Tl("no_cves", "No CVEs detected!"))) + + return output.Suppress + } + + hist := make([]*SeverityCountOutput, 0, len(rd.data.Histogram)) + totalCount := 0 + for _, h := range rd.data.Histogram { + totalCount += h.Count + var ho *SeverityCountOutput + if h.Severity == "CRITICAL" { + ho = &SeverityCountOutput{ + fmt.Sprintf("[ERROR]%d[/RESET]", h.Count), + fmt.Sprintf("[ERROR]%s[/RESET]", h.Severity), + } + } else { + ho = &SeverityCountOutput{ + fmt.Sprintf("%d", h.Count), + h.Severity, + } + } + hist = append(hist, ho) + } + rd.output.Print(output.Heading(fmt.Sprintf("%d Vulnerabilities", totalCount))) + rd.output.Print(hist) + + rd.output.Print(output.Heading(fmt.Sprintf("%d Affected Packages", len(rd.data.Packages)))) + for _, ap := range rd.data.Packages { + rd.output.Print(fmt.Sprintf("[NOTICE]%s %s[/RESET]", ap.Name, ap.Version)) + rd.output.Print(locale.Tl("report_package_vulnerabilities", "{{.V0}} Vulnerabilities", strconv.Itoa(len(ap.Details)))) + + sort.SliceStable(ap.Details, func(i, j int) bool { + sevI := ap.Details[i].Severity + sevJ := ap.Details[j].Severity + si := medmodel.ParseSeverityIndex(sevI) + sj := medmodel.ParseSeverityIndex(sevJ) + if si < sj { + return true + } + if si == sj { + return sevI < sevJ + } + return false + }) + + for i, d := range ap.Details { + bar := "├─" + if i == len(ap.Details)-1 { + bar = "└─" + } + severity := d.Severity + if severity == "CRITICAL" { + severity = fmt.Sprintf("[ERROR]%-10s[/RESET]", severity) + } + rd.output.Print(fmt.Sprintf(" %s %-10s [ACTIONABLE]%s[/RESET]", bar, severity, d.CveId)) + } + rd.output.Print("") + } + + rd.output.Print("") + rd.output.Print([]string{ + locale.Tl("cve_report_hint_cve", "To view a specific CVE, run [ACTIONABLE]state cve open [cve-id][/RESET]."), + }) + return output.Suppress +} diff --git a/locale/en-us.yaml b/locale/en-us.yaml index 40a72b6291..68b4862c0e 100644 --- a/locale/en-us.yaml +++ b/locale/en-us.yaml @@ -1633,3 +1633,7 @@ err_lock_version_invalid: other: "The locked version '{{.V0}}' could not be verified, are you sure it's valid?" config_get_error: other: Failed to read configuration. +cve_needs_authentication: + other: You need to be authenticated in order to access vulnerability information about your project. +auth_tip: + other: Run `state auth` to authenticate. diff --git a/pkg/platform/api/mediator/model/cve.go b/pkg/platform/api/mediator/model/cve.go index 9902578b90..d013be87a4 100644 --- a/pkg/platform/api/mediator/model/cve.go +++ b/pkg/platform/api/mediator/model/cve.go @@ -1,6 +1,35 @@ package model -import "github.com/go-openapi/strfmt" +import ( + "strings" + + "github.com/go-openapi/strfmt" +) + +type Severity int + +const ( + Critical Severity = iota + High + Moderate + Low + Unknown +) + +func ParseSeverityIndex(severity string) Severity { + switch strings.ToUpper(severity) { + case "CRITICAL": + return Critical + case "HIGH": + return High + case "MODERATE": + return Moderate + case "LOW": + return Low + default: + return Unknown + } +} type ProjectVulnerabilities struct { Project struct { @@ -28,8 +57,10 @@ type IngredientVulnerability struct { } type Vulnerability struct { - Version string `json:"ingredient_version"` - Severity string `json:"severity"` + Version string `json:"ingredient_version"` + Severity string `json:"severity"` + CveId string `json:"cve_id"` + AltIds []string `json:"alt_ids"` } type Organization struct { diff --git a/pkg/platform/model/cve.go b/pkg/platform/model/cve.go index e563de2b65..7424de3737 100644 --- a/pkg/platform/model/cve.go +++ b/pkg/platform/model/cve.go @@ -9,6 +9,13 @@ import ( "github.com/ActiveState/cli/pkg/platform/authentication" ) +type Vulnerability model.Vulnerability +type PackageVulnerability struct { + Name string `json:"name"` + Version string `json:"version"` + Details []Vulnerability `json:"cves"` +} + // FetchProjectVulnerabilities returns the vulnerability information of a project func FetchProjectVulnerabilities(auth *authentication.Auth, org, project string) (*model.ProjectVulnerabilities, error) { // This should be removed by https://www.pivotaltracker.com/story/show/176508740 @@ -33,3 +40,36 @@ func FetchProjectVulnerabilities(auth *authentication.Auth, org, project string) return &resp, nil } + +func ExtractPackageVulnerabilities(ingredients []model.IngredientVulnerability) []PackageVulnerability { + var packageVulnerabilities []PackageVulnerability + visited := make(map[string]struct{}) + for _, v := range ingredients { + if len(v.Vulnerabilities) == 0 { + continue + } + + // Remove this block with story https://www.pivotaltracker.com/story/show/176508772 + // filter double entries + if _, ok := visited[v.Name]; ok { + continue + } + visited[v.Name] = struct{}{} + + cves := make(map[string][]Vulnerability) + for _, ve := range v.Vulnerabilities { + if _, ok := cves[ve.Version]; !ok { + cves[ve.Version] = []Vulnerability{} + } + cves[ve.Version] = append(cves[ve.Version], Vulnerability(ve)) + } + + for ver, vuls := range cves { + packageVulnerabilities = append(packageVulnerabilities, PackageVulnerability{ + v.Name, ver, vuls, + }) + } + } + + return packageVulnerabilities +} diff --git a/test/integration/cve_int_test.go b/test/integration/cve_int_test.go index 6866b4f843..757623170a 100644 --- a/test/integration/cve_int_test.go +++ b/test/integration/cve_int_test.go @@ -20,22 +20,44 @@ func (suite *CveIntegrationTestSuite) TestCveSummary() { ts.LoginAsPersistentUser() - ts.PrepareActiveStateYAML(`project: https://platform.activestate.com/ActiveState/ActivePython-3.7`) + ts.PrepareActiveStateYAML(`project: https://platform.activestate.com/ActiveState-CLI/VulnerablePython-3.7`) cp := ts.Spawn("cve") - cp.Expect("ActivePython-3.7") + cp.Expect("VulnerablePython-3.7") cp.Expect("0b87e7a4-dc62-46fd-825b-9c35a53fe0a2") - cp.Expect("37 Vulnerabilities") + cp.Expect("Vulnerabilities") cp.Expect("6") cp.Expect("CRITICAL") - cp.Expect("10 Affected Packages") + cp.Expect("13 Affected Packages") cp.Expect("tensorflow") cp.Expect("1.12.0") cp.Expect("18") cp.ExpectExitCode(0) } +func (suite *CveIntegrationTestSuite) TestCveReport() { + suite.OnlyRunForTags(tagsuite.Cve) + + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.LoginAsPersistentUser() + + cp := ts.Spawn("cve", "report", "ActiveState-CLI/VulnerablePython-3.7") + cp.Expect("Commit ID") + + cp.Expect("Vulnerabilities") + cp.Expect("6") + cp.Expect("CRITICAL") + cp.Expect("13 Affected Packages") + cp.Expect("tensorflow") + cp.Expect("1.12.0") + cp.Expect("CRITICAL") + cp.Expect("CVE-2019-16778") + cp.ExpectExitCode(0) +} + func (suite *CveIntegrationTestSuite) TestCveNoVulnerabilities() { suite.OnlyRunForTags(tagsuite.Cve) @@ -48,7 +70,10 @@ func (suite *CveIntegrationTestSuite) TestCveNoVulnerabilities() { cp := ts.Spawn("cve") cp.Expect("No CVEs detected") + cp.ExpectExitCode(0) + cp = ts.Spawn("cve", "report") + cp.Expect("No CVEs detected") cp.ExpectExitCode(0) }