diff --git a/cmd/state/internal/cmdtree/cve.go b/cmd/state/internal/cmdtree/cve.go index 0aa91b5a6e..8f0f7b7116 100644 --- a/cmd/state/internal/cmdtree/cve.go +++ b/cmd/state/internal/cmdtree/cve.go @@ -10,16 +10,22 @@ import ( func newCveCommand(prime *primer.Values) *captain.Command { runner := cve.NewCve(prime) + params := cve.Params{Namespace: &project.Namespaced{}} cmd := captain.NewCommand( "security", - locale.Tl("cve_title", "Vulnerability Summary"), - locale.Tl("cve_description", "Show a summary of project vulnerabilities"), + locale.T("cve_title"), + locale.T("cve_description"), prime, []*captain.Flag{}, - []*captain.Argument{}, - func(_ *captain.Command, _ []string) error { - return runner.Run() + []*captain.Argument{ + { + Name: locale.T("cve_namespace_arg"), + Description: locale.T("cve_namespace_arg_description"), + Value: params.Namespace, + }, + }, func(_ *captain.Command, _ []string) error { + return runner.Run(¶ms) }, ) cmd.SetGroup(PlatformGroup) @@ -29,29 +35,30 @@ func newCveCommand(prime *primer.Values) *captain.Command { return cmd } +// newReportCommand is a hidden, legacy alias of the parent command func newReportCommand(prime *primer.Values) *captain.Command { - report := cve.NewReport(prime) - params := cve.ReportParams{ - Namespace: &project.Namespaced{}, - } + report := cve.NewCve(prime) + params := cve.Params{Namespace: &project.Namespaced{}} - return captain.NewCommand( + cmd := captain.NewCommand( "report", - locale.Tl("cve_report_title", "Vulnerability Report"), - locale.Tl("cve_report_cmd_description", "Show a detailed report of project vulnerabilities"), + locale.T("cve_title"), + locale.T("cve_description"), prime, []*captain.Flag{}, []*captain.Argument{ { - Name: locale.Tl("cve_report_namespace_arg", "org/project"), - Description: locale.Tl("cve_report_namespace_arg_description", "The project for which the report is created"), + Name: locale.T("cve_namespace_arg"), + Description: locale.T("cve_namespace_arg_description"), Value: params.Namespace, }, - }, - func(_ *captain.Command, _ []string) error { + }, func(_ *captain.Command, _ []string) error { return report.Run(¶ms) }, - ).SetSupportsStructuredOutput() + ) + cmd.SetSupportsStructuredOutput() + cmd.SetHidden(true) + return cmd } func newOpenCommand(prime *primer.Values) *captain.Command { diff --git a/internal/locale/locales/en-us.yaml b/internal/locale/locales/en-us.yaml index 254e29518d..fa08df099d 100644 --- a/internal/locale/locales/en-us.yaml +++ b/internal/locale/locales/en-us.yaml @@ -1087,6 +1087,14 @@ err_read_projectfile: other: The activestate.yaml at {{.V0}} could not be read. err_auth_fail_totp: other: A two-factor authentication code is required. +cve_title: + other: Vulnerability Summary +cve_description: + other: Show a summary of project vulnerabilities +cve_namespace_arg: + other: org/project +cve_namespace_arg_description: + other: The project for which the report is created cve_needs_authentication: other: You need to be authenticated in order to access vulnerability information about your project. auth_tip: diff --git a/internal/runners/cve/cve.go b/internal/runners/cve/cve.go index 1bdbc32e6b..6a9a8d0f29 100644 --- a/internal/runners/cve/cve.go +++ b/internal/runners/cve/cve.go @@ -2,6 +2,9 @@ package cve import ( "fmt" + "sort" + "strconv" + "time" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" @@ -14,82 +17,76 @@ import ( "github.com/ActiveState/cli/pkg/project" ) +type primeable interface { + primer.Projecter + primer.Auther + primer.Outputer +} + type Cve struct { proj *project.Project auth *authentication.Auth out output.Outputer } -type outputData struct { - Project string `json:"project"` - CommitID string `json:"commitID"` - Histogram []medmodel.SeverityCount `json:"vulnerability_histogram"` - Packages []ByPackageOutput `json:"packages"` +type CveInfo struct { + Project string `locale:"project,Project"` + CommitID string `locale:"commit_id,Commit ID"` + Date string `locale:"generated_on,Generated on"` } -type cveOutput struct { - output output.Outputer - data *outputData +func NewCve(prime primeable) *Cve { + return &Cve{prime.Project(), prime.Auth(), prime.Output()} } -type ByPackageOutput struct { - Name string `json:"name" locale:"state_cve_package_name,Name"` - Version string `json:"version" locale:"state_cve_package_version,Version"` - CveCount int `json:"cve_count" locale:"state_cve_package_count,Count"` +type Params struct { + Namespace *project.Namespaced } -type ProjectInfo struct { - Project string `locale:"project,Project"` - CommitID string `locale:"commit_id,Commit ID"` +type cveData 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 primeable interface { - primer.Projecter - primer.Auther - primer.Outputer -} - -func NewCve(prime *primer.Values) *Cve { - return &Cve{prime.Project(), prime.Auth(), prime.Output()} +type cveOutput struct { + output output.Outputer + data *cveData } -func (c *Cve) Run() error { - if c.proj == nil { - return locale.NewError("cve_no_project", "No project found at the current directory.") +func (r *Cve) Run(params *Params) error { + if !params.Namespace.IsValid() && r.proj == nil { + return locale.NewInputError("err_no_project") } - c.out.Notice(locale.Tr("operating_message", c.proj.NamespaceString(), c.proj.Dir())) - if !c.auth.Authenticated() { + if !r.auth.Authenticated() { return errs.AddTips( locale.NewError("cve_needs_authentication"), locale.T("auth_tip"), ) } - commitID, err := commitmediator.Get(c.proj) - if err != nil { - return errs.Wrap(err, "Could not get local commit") - } - - resp, err := model.FetchCommitVulnerabilities(c.auth, commitID.String()) + vulnerabilities, err := r.fetchVulnerabilities(*params.Namespace) if err != nil { return locale.WrapError(err, "cve_mediator_resp", "Failed to retrieve vulnerability information") } - details := model.ExtractPackageVulnerabilities(resp.Sources) - packageVulnerabilities := make([]ByPackageOutput, 0, len(details)) - for _, v := range details { - packageVulnerabilities = append(packageVulnerabilities, ByPackageOutput{ - v.Name, v.Version, len(v.Details), - }) + packageVulnerabilities := model.ExtractPackageVulnerabilities(vulnerabilities.Sources) + + ns := params.Namespace + if !ns.IsValid() { + ns = r.proj.Namespace() } - c.out.Print(&cveOutput{ - c.out, - &outputData{ - Project: c.proj.Name(), - CommitID: resp.CommitID, - Histogram: resp.VulnerabilityHistogram, + r.out.Print(&cveOutput{ + r.out, + &cveData{ + Project: ns.String(), + CommitID: vulnerabilities.CommitID, + Date: time.Now(), + Histogram: vulnerabilities.VulnerabilityHistogram, Packages: packageVulnerabilities, }, }) @@ -97,38 +94,62 @@ func (c *Cve) Run() error { return nil } +func (r *Cve) fetchVulnerabilities(namespaceOverride project.Namespaced) (*medmodel.CommitVulnerabilities, error) { + if namespaceOverride.IsValid() && namespaceOverride.CommitID == nil { + resp, err := model.FetchProjectVulnerabilities(r.auth, namespaceOverride.Owner, namespaceOverride.Project) + if err != nil { + return nil, errs.Wrap(err, "Failed to fetch vulnerability information for project %s", namespaceOverride.String()) + } + return resp.Commit, nil + } + + // fetch by commit ID + var commitID string + if namespaceOverride.IsValid() { + commitID = namespaceOverride.CommitID.String() + } else { + var err error + commitUUID, err := commitmediator.Get(r.proj) + if err != nil { + return nil, errs.Wrap(err, "Unable to get local commit") + } + commitID = commitUUID.String() + } + resp, err := model.FetchCommitVulnerabilities(r.auth, commitID) + if err != nil { + return nil, errs.Wrap(err, "Failed to fetch vulnerability information for commit %s", commitID) + } + return resp, nil +} + type SeverityCountOutput struct { Count string `locale:"count,Count" json:"count"` Severity string `locale:"severity,Severity" json:"severity"` } -func (od *cveOutput) printFooter() { - od.output.Print("") - od.output.Print([]string{ - locale.Tl("cve_hint_report", "To view a detailed report for this runtime, run [ACTIONABLE]state security report[/RESET]"), - locale.Tl("cve_hint_specific_report", "For a specific runtime, run [ACTIONABLE]state security report [/RESET]"), - }) -} - -func (od *cveOutput) MarshalOutput(format output.Format) interface{} { - pi := &ProjectInfo{ - od.data.Project, - od.data.CommitID, +func (rd *cveOutput) MarshalOutput(format output.Format) interface{} { + if format != output.PlainFormatName { + return rd.data + } + ri := &CveInfo{ + fmt.Sprintf("[ACTIONABLE]%s[/RESET]", rd.data.Project), + rd.data.CommitID, + rd.data.Date.Format("01/02/06"), } - od.output.Print(struct { - *ProjectInfo `opts:"verticalTable"` - }{pi}) - - 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() + rd.output.Print(struct { + *CveInfo `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(od.data.Histogram)) + hist := make([]*SeverityCountOutput, 0, len(rd.data.Histogram)) totalCount := 0 - for _, h := range od.data.Histogram { + for _, h := range rd.data.Histogram { totalCount += h.Count var ho *SeverityCountOutput if h.Severity == "CRITICAL" { @@ -144,16 +165,49 @@ func (od *cveOutput) MarshalOutput(format output.Format) interface{} { } hist = append(hist, ho) } - od.output.Print(output.Title(fmt.Sprintf("%d Vulnerabilities", totalCount))) - od.output.Print(hist) + rd.output.Print(output.Title(fmt.Sprintf("%d Vulnerabilities", totalCount))) + rd.output.Print(hist) + + rd.output.Print(output.Title(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("cve_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 + }) - od.output.Print(output.Title(fmt.Sprintf("%d Affected Packages", len(od.data.Packages)))) - od.output.Print(od.data.Packages) + 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("") + } - od.printFooter() + rd.output.Print("") + rd.output.Print([]string{ + locale.Tl("cve_hint_cve", "To view a specific CVE, run [ACTIONABLE]state security open [cve-id][/RESET]."), + }) return output.Suppress } -func (od *cveOutput) MarshalStructured(format output.Format) interface{} { - return od.data +func (rd *cveOutput) MarshalStructured(format output.Format) interface{} { + return rd.data } diff --git a/internal/runners/cve/report.go b/internal/runners/cve/report.go deleted file mode 100644 index 8315c71238..0000000000 --- a/internal/runners/cve/report.go +++ /dev/null @@ -1,201 +0,0 @@ -package cve - -import ( - "fmt" - "sort" - "strconv" - "time" - - "github.com/ActiveState/cli/internal/errs" - "github.com/ActiveState/cli/internal/locale" - "github.com/ActiveState/cli/internal/output" - "github.com/ActiveState/cli/internal/runbits/commitmediator" - 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 reportOutput struct { - output output.Outputer - data *reportData -} - -func (r *Report) Run(params *ReportParams) error { - if !params.Namespace.IsValid() && r.proj == nil { - return locale.NewInputError("err_no_project") - } - - if !r.auth.Authenticated() { - return errs.AddTips( - locale.NewError("cve_needs_authentication"), - locale.T("auth_tip"), - ) - } - - vulnerabilities, err := r.fetchVulnerabilities(*params.Namespace) - if err != nil { - return locale.WrapError(err, "cve_mediator_resp", "Failed to retrieve vulnerability information") - } - - packageVulnerabilities := model.ExtractPackageVulnerabilities(vulnerabilities.Sources) - - ns := params.Namespace - if !ns.IsValid() { - ns = r.proj.Namespace() - } - - r.out.Print(&reportOutput{ - r.out, - &reportData{ - Project: ns.String(), - CommitID: vulnerabilities.CommitID, - Date: time.Now(), - Histogram: vulnerabilities.VulnerabilityHistogram, - Packages: packageVulnerabilities, - }, - }) - - return nil -} - -func (r *Report) fetchVulnerabilities(namespaceOverride project.Namespaced) (*medmodel.CommitVulnerabilities, error) { - if namespaceOverride.IsValid() && namespaceOverride.CommitID == nil { - resp, err := model.FetchProjectVulnerabilities(r.auth, namespaceOverride.Owner, namespaceOverride.Project) - if err != nil { - return nil, errs.Wrap(err, "Failed to fetch vulnerability information for project %s", namespaceOverride.String()) - } - return resp.Commit, nil - } - - // fetch by commit ID - var commitID string - if namespaceOverride.IsValid() { - commitID = namespaceOverride.CommitID.String() - } else { - var err error - commitUUID, err := commitmediator.Get(r.proj) - if err != nil { - return nil, errs.Wrap(err, "Unable to get local commit") - } - commitID = commitUUID.String() - } - resp, err := model.FetchCommitVulnerabilities(r.auth, commitID) - if err != nil { - return nil, errs.Wrap(err, "Failed to fetch vulnerability information for commit %s", commitID) - } - return resp, nil -} - -func (rd *reportOutput) 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.Title(fmt.Sprintf("%d Vulnerabilities", totalCount))) - rd.output.Print(hist) - - rd.output.Print(output.Title(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 security open [cve-id][/RESET]."), - }) - return output.Suppress -} - -func (rd *reportOutput) MarshalStructured(format output.Format) interface{} { - return rd.data -} diff --git a/test/integration/cve_int_test.go b/test/integration/cve_int_test.go index c6de5b664e..8d2802e71c 100644 --- a/test/integration/cve_int_test.go +++ b/test/integration/cve_int_test.go @@ -12,7 +12,7 @@ type CveIntegrationTestSuite struct { tagsuite.Suite } -func (suite *CveIntegrationTestSuite) TestCveSummary() { +func (suite *CveIntegrationTestSuite) TestCve() { suite.OnlyRunForTags(tagsuite.Cve) ts := e2e.New(suite.T(), false) @@ -20,30 +20,7 @@ func (suite *CveIntegrationTestSuite) TestCveSummary() { ts.LoginAsPersistentUser() - ts.PrepareProject("ActiveState-CLI/VulnerablePython-3.7", "0b87e7a4-dc62-46fd-825b-9c35a53fe0a2") - - cp := ts.Spawn("cve") - cp.Expect("Operating on project") - cp.Expect("ActiveState-CLI/VulnerablePython-3.7") - cp.Expect("VulnerablePython-3.7") - cp.Expect("0b87e7a4-dc62-46fd-825b-9c35a53fe0a2") - - cp.Expect("Vulnerabilities") - cp.Expect("CRITICAL") - cp.Expect("Affected Packages") - cp.Expect("tensorflow") - 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 := ts.Spawn("cve", "ActiveState-CLI/VulnerablePython-3.7") cp.Expect("Commit ID") cp.Expect("0b87e7a4-dc62-46fd-825b-9c35a53fe0a2") @@ -56,7 +33,7 @@ func (suite *CveIntegrationTestSuite) TestCveReport() { cp.ExpectExitCode(0) // make sure that we can select by commit id - cp = ts.Spawn("cve", "report", "ActiveState-CLI/VulnerablePython-3.7#3b222e23-64b9-4ca1-93ee-7b8a75b18c30") + cp = ts.Spawn("cve", "ActiveState-CLI/VulnerablePython-3.7#3b222e23-64b9-4ca1-93ee-7b8a75b18c30") cp.Expect("Commit ID") cp.Expect("3b222e23-64b9-4ca1-93ee-7b8a75b18c30") @@ -80,7 +57,7 @@ func (suite *CveIntegrationTestSuite) TestCveNoVulnerabilities() { cp.Expect("No CVEs detected") cp.ExpectExitCode(0) - cp = ts.Spawn("cve", "report") + cp = ts.Spawn("cve", "report") // legacy alias cp.Expect("No CVEs detected") cp.ExpectExitCode(0) } @@ -93,7 +70,7 @@ func (suite *CveIntegrationTestSuite) TestCveInvalidProject() { ts.LoginAsPersistentUser() - cp := ts.Spawn("cve", "report", "invalid/invalid") + cp := ts.Spawn("cve", "invalid/invalid") cp.Expect("Found no project with specified organization and name") cp.ExpectNotExitCode(0) @@ -112,19 +89,13 @@ func (suite *CveIntegrationTestSuite) TestJSON() { cp.Expect("Checked out") cp.ExpectExitCode(0) - cp = ts.Spawn("cve", "-o", "json") - cp.Expect(`"project":`) - cp.Expect(`"commitID":`) - cp.ExpectExitCode(0) - AssertValidJSON(suite.T(), cp) - - cp = ts.Spawn("cve", "report", "-o", "editor") + cp = ts.Spawn("cve", "-o", "editor") cp.Expect(`"project":`) cp.Expect(`"commitID":`) cp.ExpectExitCode(0) // AssertValidJSON(suite.T(), cp) // report is too large to fit in terminal snapshot } -func TestCveIntegraionTestSuite(t *testing.T) { +func TestCveIntegrationTestSuite(t *testing.T) { suite.Run(t, new(CveIntegrationTestSuite)) }