From e5f675e2d1c250e6188be20b29e404bdab696a74 Mon Sep 17 00:00:00 2001 From: Assaf Attias <49212512+attiasas@users.noreply.github.com> Date: Fri, 30 Aug 2024 15:43:36 +0300 Subject: [PATCH] Improve Job Summary (#137) --- cli/scancommands.go | 2 +- cli/scancommands_test.go | 2 +- commands/curation/curationaudit.go | 50 +- commands/curation/curationaudit_test.go | 45 +- commands/scan/buildscan.go | 8 +- commands/scan/dockerscan.go | 15 +- commands/scan/scan.go | 13 +- formats/summary.go | 377 +++++--- formats/summary_test.go | 464 +++++++--- go.mod | 12 +- go.sum | 24 +- resources/resources.go | 3 + resources/statusIcons/failed.svg | 1 + resources/statusIcons/passed.svg | 1 + .../jobSummary/binary_vulnerabilities.md | 1 + .../jobSummary/build_scan_vulnerabilities.md | 1 + .../jobSummary/docker_vulnerabilities.md | 1 + .../other/jobSummary/multi_command_job.md | 21 - .../other/jobSummary/no_violations.md | 1 + .../other/jobSummary/no_vulnerabilities.md | 1 + .../other/jobSummary/security_section.md | 8 + .../testdata/other/jobSummary/single_issue.md | 1 - .../other/jobSummary/single_no_issue.md | 3 - tests/testdata/other/jobSummary/violations.md | 1 + .../jobSummary/violations_not_defined.md | 1 + .../violations_not_extended_view.md | 1 + utils/results.go | 39 +- utils/results_test.go | 159 +--- utils/resultstable.go | 6 +- utils/resultwriter.go | 198 ++++ utils/resultwriter_test.go | 152 ++++ utils/securityJobSummary.go | 848 +++++++++--------- utils/securityJobSummary_test.go | 519 ++++++++--- utils/severityutils/severity.go | 20 + utils/test_mocks.go | 2 + utils/utils.go | 10 + utils/xsc/analyticsmetrics.go | 2 +- 37 files changed, 1936 insertions(+), 1077 deletions(-) create mode 100644 resources/resources.go create mode 100644 resources/statusIcons/failed.svg create mode 100644 resources/statusIcons/passed.svg create mode 100644 tests/testdata/other/jobSummary/binary_vulnerabilities.md create mode 100644 tests/testdata/other/jobSummary/build_scan_vulnerabilities.md create mode 100644 tests/testdata/other/jobSummary/docker_vulnerabilities.md delete mode 100644 tests/testdata/other/jobSummary/multi_command_job.md create mode 100644 tests/testdata/other/jobSummary/no_violations.md create mode 100644 tests/testdata/other/jobSummary/no_vulnerabilities.md create mode 100644 tests/testdata/other/jobSummary/security_section.md delete mode 100644 tests/testdata/other/jobSummary/single_issue.md delete mode 100644 tests/testdata/other/jobSummary/single_no_issue.md create mode 100644 tests/testdata/other/jobSummary/violations.md create mode 100644 tests/testdata/other/jobSummary/violations_not_defined.md create mode 100644 tests/testdata/other/jobSummary/violations_not_extended_view.md diff --git a/cli/scancommands.go b/cli/scancommands.go index c969eadb..17ea3e9a 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -563,7 +563,7 @@ func ShouldRunCurationAfterFailure(c *components.Context, tech techutils.Technol if !IsSupportedCommandForCurationInspect(cmdName) { return } - if os.Getenv(coreutils.OutputDirPathEnv) == "" || + if os.Getenv(coreutils.SummaryOutputDirPathEnv) == "" || os.Getenv(SkipCurationAfterFailureEnv) == "true" { return } diff --git a/cli/scancommands_test.go b/cli/scancommands_test.go index 7ce82286..3e829ed4 100644 --- a/cli/scancommands_test.go +++ b/cli/scancommands_test.go @@ -95,7 +95,7 @@ func TestShouldRunCurationAfterFailure(t *testing.T) { defer callBack() } if tt.envOutputDirPath != "" { - callBack2 := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.OutputDirPathEnv, tt.envOutputDirPath) + callBack2 := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.SummaryOutputDirPathEnv, tt.envOutputDirPath) defer callBack2() } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 68beaae0..65b988a6 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/exp/maps" + "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/parallel" rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" @@ -240,43 +242,43 @@ func (ca *CurationAuditCommand) Run() (err error) { for projectPath, packagesStatus := range results { err = errors.Join(err, printResult(ca.OutputFormat(), projectPath, packagesStatus.packagesStatus)) } - - err = errors.Join(err, utils.RecordSecurityCommandOutput(utils.ScanCommandSummaryResult{Results: convertResultsToSummary(results), Section: utils.Curation})) + err = errors.Join(err, utils.RecordSecurityCommandSummary(utils.NewCurationSummary(convertResultsToSummary(results)))) return } -func convertResultsToSummary(results map[string]*CurationReport) formats.SummaryResults { - summaryResults := formats.SummaryResults{} +func convertResultsToSummary(results map[string]*CurationReport) formats.ResultsSummary { + summaryResults := formats.ResultsSummary{} for projectPath, packagesStatus := range results { - blocked := convertBlocked(packagesStatus.packagesStatus) - approved := packagesStatus.totalNumberOfPackages - blocked.GetCountOfKeys(false) - - summaryResults.Scans = append(summaryResults.Scans, formats.ScanSummaryResult{Target: projectPath, + summaryResults.Scans = append(summaryResults.Scans, formats.ScanSummary{Target: projectPath, CuratedPackages: &formats.CuratedPackages{ - Blocked: blocked, - Approved: approved, - }}) + PackageCount: packagesStatus.totalNumberOfPackages, + Blocked: getBlocked(packagesStatus.packagesStatus), + }, + }) } return summaryResults } -func convertBlocked(pkgStatus []*PackageStatus) formats.TwoLevelSummaryCount { - blocked := formats.TwoLevelSummaryCount{} +func getBlocked(pkgStatus []*PackageStatus) []formats.BlockedPackages { + blockedMap := map[string]formats.BlockedPackages{} for _, pkg := range pkgStatus { for _, policy := range pkg.Policy { - polAndCond := formatPolicyAndCond(policy.Policy, policy.Condition) - if _, ok := blocked[polAndCond]; !ok { - blocked[polAndCond] = formats.SummaryCount{} + polAndCondKey := getPolicyAndConditionId(policy.Policy, policy.Condition) + if _, ok := blockedMap[polAndCondKey]; !ok { + blockedMap[polAndCondKey] = formats.BlockedPackages{ + Policy: policy.Policy, + Condition: policy.Condition, + Packages: make(map[string]int), + } } uniqId := getPackageId(pkg.PackageName, pkg.PackageVersion) - blocked[polAndCond][uniqId]++ + if _, ok := blockedMap[polAndCondKey].Packages[uniqId]; !ok { + blockedMap[polAndCondKey].Packages[uniqId] = 0 + } + blockedMap[polAndCondKey].Packages[uniqId]++ } } - return blocked -} - -func formatPolicyAndCond(policy, cond string) string { - return fmt.Sprintf("Policy: %s, Condition: %s", policy, cond) + return maps.Values(blockedMap) } // The unique identifier of a package includes the package name with its version @@ -284,6 +286,10 @@ func getPackageId(packageName, packageVersion string) string { return fmt.Sprintf("%s:%s", packageName, packageVersion) } +func getPolicyAndConditionId(policy, condition string) string { + return fmt.Sprintf("%s:%s", policy, condition) +} + func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { techs := techutils.DetectedTechnologiesList() for _, tech := range techs { diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 5777bc91..a61a5b84 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -857,7 +857,7 @@ func Test_convertResultsToSummary(t *testing.T) { tests := []struct { name string input map[string]*CurationReport - expected formats.SummaryResults + expected formats.ResultsSummary }{ { name: "results for one result", @@ -882,17 +882,17 @@ func Test_convertResultsToSummary(t *testing.T) { totalNumberOfPackages: 5, }, }, - expected: formats.SummaryResults{ - Scans: []formats.ScanSummaryResult{ + expected: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ { Target: "project1", CuratedPackages: &formats.CuratedPackages{ - Blocked: formats.TwoLevelSummaryCount{ - formatPolicyAndCond("policy1", "cond1"): formats.SummaryCount{ - getPackageId("test1", "1.0.0"): 1, - }, - }, - Approved: 4, + PackageCount: 5, + Blocked: []formats.BlockedPackages{{ + Policy: "policy1", + Condition: "cond1", + Packages: map[string]int{"test1:1.0.0": 1}, + }}, }, }, }, @@ -950,25 +950,27 @@ func Test_convertResultsToSummary(t *testing.T) { }, }, }, - totalNumberOfPackages: 5, + totalNumberOfPackages: 6, }, }, - expected: formats.SummaryResults{ - Scans: []formats.ScanSummaryResult{ + expected: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ { Target: "project1", CuratedPackages: &formats.CuratedPackages{ - Blocked: formats.TwoLevelSummaryCount{ - formatPolicyAndCond("policy1", "cond1"): formats.SummaryCount{ - getPackageId("test1", "1.0.0"): 1, + PackageCount: 6, + Blocked: []formats.BlockedPackages{ + { + Policy: "policy1", + Condition: "cond1", + Packages: map[string]int{"test1:1.0.0": 1}, }, - formatPolicyAndCond("policy2", "cond2"): formats.SummaryCount{ - getPackageId("test1", "1.0.0"): 1, - getPackageId("test2", "2.0.0"): 1, - getPackageId("test3", "3.0.0"): 1, + { + Policy: "policy2", + Condition: "cond2", + Packages: map[string]int{"test1:1.0.0": 1, "test2:2.0.0": 1, "test3:3.0.0": 1}, }, }, - Approved: 2, }, }, }, @@ -977,8 +979,7 @@ func Test_convertResultsToSummary(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := convertResultsToSummary(tt.input) - assert.Equal(t, tt.expected, results) + assert.ElementsMatch(t, tt.expected.Scans, convertResultsToSummary(tt.input).Scans) }) } } diff --git a/commands/scan/buildscan.go b/commands/scan/buildscan.go index 66ad5d90..40149491 100644 --- a/commands/scan/buildscan.go +++ b/commands/scan/buildscan.go @@ -183,7 +183,13 @@ func (bsc *BuildScanCommand) runBuildScanAndPrintResults(xrayManager *xray.XrayS } } } - err = utils.RecordSecurityCommandOutput(utils.ScanCommandSummaryResult{Results: scanResults.GetSummary(), Section: utils.Build}) + err = utils.RecordSecurityCommandSummary(utils.NewBuildScanSummary( + scanResults, + bsc.serverDetails, + bsc.includeVulnerabilities, + bsc.buildConfiguration.GetProject() != "", + params.BuildName, params.BuildNumber, + )) return } diff --git a/commands/scan/dockerscan.go b/commands/scan/dockerscan.go index 2c6e27a6..1ba036e4 100644 --- a/commands/scan/dockerscan.go +++ b/commands/scan/dockerscan.go @@ -97,15 +97,18 @@ func (dsc *DockerScanCommand) Run() (err error) { } }() return dsc.ScanCommand.RunAndRecordResults(func(scanResults *utils.Results) (err error) { - if scanResults == nil || len(scanResults.ScaResults) == 0 { + if scanResults == nil { return } - for i := range scanResults.ScaResults { - // Set the image tag as the target for the scan results (will show `image.tar` as target if not set) - scanResults.ScaResults[i].Target = dsc.imageTag - } dsc.analyticsMetricsService.UpdateGeneralEvent(dsc.analyticsMetricsService.CreateXscAnalyticsGeneralEventFinalizeFromAuditResults(scanResults)) - return utils.RecordSecurityCommandOutput(utils.ScanCommandSummaryResult{Results: scanResults.GetSummary(), Section: utils.Binary}) + + return utils.RecordSecurityCommandSummary(utils.NewDockerScanSummary( + scanResults, + dsc.ScanCommand.serverDetails, + dsc.ScanCommand.includeVulnerabilities, + hasViolationContext(dsc.ScanCommand.watches, dsc.ScanCommand.projectKey), + dsc.imageTag, + )) }) } diff --git a/commands/scan/scan.go b/commands/scan/scan.go index a98cfbc2..6f0ba747 100644 --- a/commands/scan/scan.go +++ b/commands/scan/scan.go @@ -191,10 +191,19 @@ func (scanCmd *ScanCommand) indexFile(filePath string) (*xrayUtils.BinaryGraphNo func (scanCmd *ScanCommand) Run() (err error) { return scanCmd.RunAndRecordResults(func(scanResults *utils.Results) error { - return utils.RecordSecurityCommandOutput(utils.ScanCommandSummaryResult{Results: scanResults.GetSummary(), Section: utils.Binary}) + return utils.RecordSecurityCommandSummary(utils.NewBinaryScanSummary( + scanResults, + scanCmd.serverDetails, + scanCmd.includeVulnerabilities, + hasViolationContext(scanCmd.watches, scanCmd.projectKey), + )) }) } +func hasViolationContext(watches []string, projectKey string) bool { + return len(watches) > 0 || projectKey != "" +} + func (scanCmd *ScanCommand) RunAndRecordResults(recordResFunc func(scanResults *utils.Results) error) (err error) { defer func() { if err != nil { @@ -312,7 +321,7 @@ func (scanCmd *ScanCommand) RunAndRecordResults(recordResFunc func(scanResults * SetIncludeVulnerabilities(scanCmd.includeVulnerabilities). SetIncludeLicenses(scanCmd.includeLicenses). SetPrintExtendedTable(scanCmd.printExtendedTable). - SetIsMultipleRootProject(true). + SetIsMultipleRootProject(scanResults.IsMultipleProject()). SetScanType(services.Binary). PrintScanResults(); err != nil { return diff --git a/formats/summary.go b/formats/summary.go index ce546aaa..4e16fe0c 100644 --- a/formats/summary.go +++ b/formats/summary.go @@ -2,193 +2,334 @@ package formats import ( "github.com/jfrog/gofrog/datastructures" + "golang.org/x/exp/slices" ) const ( - ScaScan SummarySubScanType = "SCA" - IacScan SummarySubScanType = "IAC" - SecretsScan SummarySubScanType = "Secrets" - SastScan SummarySubScanType = "SAST" - - ViolationTypeSecurity ViolationIssueType = "security" - ViolationTypeLicense ViolationIssueType = "license" - ViolationTypeOperationalRisk ViolationIssueType = "operational_risk" + IacResult SummaryResultType = "IAC" + SecretsResult SummaryResultType = "Secrets" + SastResult SummaryResultType = "SAST" + ScaResult SummaryResultType = "SCA" + ScaSecurityResult SummaryResultType = "Security" + ScaLicenseResult SummaryResultType = "License" + ScaOperationalResult SummaryResultType = "Operational" + + NoStatus = "" ) -type SummarySubScanType string -type ViolationIssueType string +type SummaryResultType string -func (v ViolationIssueType) String() string { - return string(v) +func (srt SummaryResultType) String() string { + return string(srt) } -type SummaryResults struct { - Scans []ScanSummaryResult `json:"scans"` +type ResultsSummary struct { + Scans []ScanSummary `json:"scans"` } -func (sr SummaryResults) GetTotalIssueCount() (total int) { - for _, scan := range sr.Scans { - total += scan.GetTotalIssueCount() - } - return +type ScanSummary struct { + Target string `json:"target"` + Vulnerabilities *ScanResultSummary `json:"vulnerabilities,omitempty"` + Violations *ScanViolationsSummary `json:"violations,omitempty"` + CuratedPackages *CuratedPackages `json:"curated,omitempty"` } -type ScanSummaryResult struct { - Target string `json:"target,omitempty"` - Vulnerabilities *ScanVulnerabilitiesSummary `json:"vulnerabilities,omitempty"` - Violations TwoLevelSummaryCount `json:"violations,omitempty"` - CuratedPackages *CuratedPackages `json:"curated,omitempty"` +type ScanResultSummary struct { + ScaResults *ScaScanResultSummary `json:"sca,omitempty"` + IacResults *ResultSummary `json:"iac,omitempty"` + SecretsResults *ResultSummary `json:"secrets,omitempty"` + SastResults *ResultSummary `json:"sast,omitempty"` } -type CuratedPackages struct { - Blocked TwoLevelSummaryCount `json:"blocked,omitempty"` - Approved int `json:"approved,omitempty"` +type ScanViolationsSummary struct { + Watches []string `json:"watches,omitempty"` + FailBuild bool `json:"fail_build,omitempty"` + ScanResultSummary } -type ScanVulnerabilitiesSummary struct { - ScaScanResults *ScanScaResult `json:"sca,omitempty"` - IacScanResults *SummaryCount `json:"iac,omitempty"` - SecretsScanResults *SummaryCount `json:"secrets,omitempty"` - SastScanResults *SummaryCount `json:"sast,omitempty"` +type ScaScanResultSummary struct { + ScanIds []string `json:"scan_ids,omitempty"` + MoreInfoUrls []string `json:"more_info_urls,omitempty"` + Security ResultSummary `json:"security,omitempty"` + License ResultSummary `json:"license,omitempty"` + OperationalRisk ResultSummary `json:"operational_risk,omitempty"` } -type ScanScaResult struct { - SummaryCount TwoLevelSummaryCount `json:"sca,omitempty"` - UniqueFindings int `json:"unique_findings,omitempty"` +type CuratedPackages struct { + Blocked []BlockedPackages `json:"blocked,omitempty"` + PackageCount int `json:"num_packages,omitempty"` +} + +type BlockedPackages struct { + Policy string `json:"policy,omitempty"` + Condition string `json:"condition,omitempty"` + Packages map[string]int `json:"packages"` } -func (s *ScanSummaryResult) HasIssues() bool { - return s.HasViolations() || s.HasSecurityVulnerabilities() || s.HasBlockedCuration() +func (cp *CuratedPackages) GetApprovedCount() int { + return cp.PackageCount - cp.GetBlockedCount() } -func (s *ScanSummaryResult) HasViolations() bool { - return s.Violations.GetTotal() > 0 +func (cp *CuratedPackages) GetBlockedCount() int { + parsed := datastructures.MakeSet[string]() + for _, blocked := range cp.Blocked { + for packageId, _ := range blocked.Packages { + if parsed.Exists(packageId) { + continue + } + parsed.Add(packageId) + } + } + return parsed.Size() } -func (s *ScanSummaryResult) HasSecurityVulnerabilities() bool { - return s.Vulnerabilities != nil && s.Vulnerabilities.GetTotalIssueCount() > 0 +// Severity -> status -> Count +type ResultSummary map[string]map[string]int + +func (rs ResultSummary) GetTotal(filterSeverities ...string) (total int) { + for severity, count := range rs { + if len(filterSeverities) > 0 && !slices.Contains(filterSeverities, severity) { + continue + } + for _, c := range count { + total += c + } + } + return } -func (s *ScanSummaryResult) HasBlockedCuration() bool { - return s.CuratedPackages != nil && s.CuratedPackages.Blocked.GetTotal() > 0 +func (rs *ResultsSummary) HasViolations() bool { + for _, scan := range rs.Scans { + if scan.HasViolations() { + return true + } + } + return false } -func (s *ScanSummaryResult) GetTotalIssueCount() (total int) { - if s.Vulnerabilities != nil { - total += s.Vulnerabilities.GetTotalIssueCount() +func (rs *ResultsSummary) GetTotalVulnerabilities(filterTypes ...SummaryResultType) (total int) { + for _, scan := range rs.Scans { + if scan.Vulnerabilities != nil { + total += scan.Vulnerabilities.GetTotal(filterTypes...) + } } - total += s.Violations.GetTotal() return +} +func (rs *ResultsSummary) GetTotalViolations(filterTypes ...SummaryResultType) (total int) { + for _, scan := range rs.Scans { + if scan.Violations != nil { + total += scan.Violations.GetTotal(filterTypes...) + } + } + return } -func (s *ScanSummaryResult) GetTotalViolationCount() (total int) { - return s.Violations.GetTotal() +func (sc *ScanSummary) HasCuratedPackages() bool { + return sc.CuratedPackages != nil } -func (s *ScanVulnerabilitiesSummary) GetTotalUniqueIssueCount() (total int) { - return s.getTotalIssueCount(true) +func (sc *ScanSummary) HasBlockedPackages() bool { + return sc.CuratedPackages != nil && len(sc.CuratedPackages.Blocked) > 0 } -func (s *ScanVulnerabilitiesSummary) GetTotalIssueCount() (total int) { - return s.getTotalIssueCount(false) +func (sc *ScanSummary) HasViolations() bool { + return sc.Violations != nil && sc.Violations.GetTotal() > 0 } -func (s *CuratedPackages) GetTotalPackages() int { - return s.Approved + s.Blocked.GetCountOfKeys(false) +func (sc *ScanSummary) HasVulnerabilities() bool { + return sc.Vulnerabilities != nil && sc.Vulnerabilities.GetTotal() > 0 } -func (s *ScanVulnerabilitiesSummary) getTotalIssueCount(unique bool) (total int) { - if s.ScaScanResults != nil { - if unique { - total += s.ScaScanResults.UniqueFindings - } else { - total += s.ScaScanResults.SummaryCount.GetTotal() - } +func (sc *ScanSummary) GetScanIds() (scanIds []string) { + if sc.Vulnerabilities != nil { + scanIds = append(scanIds, sc.Vulnerabilities.GetScanIds()...) } - if s.IacScanResults != nil { - total += s.IacScanResults.GetTotal() + if sc.Violations != nil { + scanIds = append(scanIds, sc.Violations.GetScanIds()...) } - if s.SecretsScanResults != nil { - total += s.SecretsScanResults.GetTotal() + return +} + +func (srs *ScanResultSummary) GetMoreInfoUrls() (urls []string) { + if srs.ScaResults != nil { + for _, url := range srs.ScaResults.MoreInfoUrls { + if url != "" { + urls = append(urls, url) + } + } } - if s.SastScanResults != nil { - total += s.SastScanResults.GetTotal() + return +} + +func (srs *ScanResultSummary) GetScanIds() (scanIds []string) { + if srs.ScaResults != nil { + scanIds = append(scanIds, srs.ScaResults.ScanIds...) } return } -func (s *ScanVulnerabilitiesSummary) GetSubScansWithIssues() []SummarySubScanType { - subScans := []SummarySubScanType{} - if s.SecretsScanResults != nil && s.SecretsScanResults.GetTotal() > 0 { - subScans = append(subScans, SecretsScan) +func (srs *ScanResultSummary) HasIssues() bool { + return srs.GetTotal() > 0 +} + +func (srs *ScanResultSummary) GetTotal(filterTypes ...SummaryResultType) (total int) { + if srs.IacResults != nil && isFilterApply(IacResult, filterTypes) { + total += srs.IacResults.GetTotal() } - if s.SastScanResults != nil && s.SastScanResults.GetTotal() > 0 { - subScans = append(subScans, SastScan) + if srs.SecretsResults != nil && isFilterApply(SecretsResult, filterTypes) { + total += srs.SecretsResults.GetTotal() } - if s.IacScanResults != nil && s.IacScanResults.GetTotal() > 0 { - subScans = append(subScans, IacScan) + if srs.SastResults != nil && isFilterApply(SastResult, filterTypes) { + total += srs.SastResults.GetTotal() } - if s.ScaScanResults != nil && s.ScaScanResults.SummaryCount.GetTotal() > 0 { - subScans = append(subScans, ScaScan) + if srs.ScaResults == nil { + return } - return subScans -} - -func (svs *ScanVulnerabilitiesSummary) GetSubScanTotalIssueCount(subScanType SummarySubScanType) (count int) { - switch subScanType { - case ScaScan: - count = svs.ScaScanResults.SummaryCount.GetTotal() - case IacScan: - count = svs.IacScanResults.GetTotal() - case SecretsScan: - count = svs.SecretsScanResults.GetTotal() - case SastScan: - count = svs.SastScanResults.GetTotal() + if isFilterApply(ScaSecurityResult, filterTypes) { + total += srs.ScaResults.Security.GetTotal() + } + if isFilterApply(ScaLicenseResult, filterTypes) { + total += srs.ScaResults.License.GetTotal() + } + if isFilterApply(ScaOperationalResult, filterTypes) { + total += srs.ScaResults.OperationalRisk.GetTotal() } return } -// Severity -> Count -type SummaryCount map[string]int +func isFilterApply(key SummaryResultType, filterTypes []SummaryResultType) bool { + if len(filterTypes) == 0 { + return true + } + for _, filterType := range filterTypes { + if key == filterType { + return true + } + } + return false +} -func (sc SummaryCount) GetTotal() int { - total := 0 - for _, count := range sc { - total += count +// Returns a ResultSummary with the counts described in the summary +// Severity -> status -> Count +func (ss *ScanResultSummary) GetSummaryBySeverity() (summary ResultSummary) { + summary = ResultSummary{} + if ss.ScaResults != nil { + summary = MergeResultSummaries(summary, ss.ScaResults.Security) + summary = MergeResultSummaries(summary, ss.ScaResults.License) + summary = MergeResultSummaries(summary, ss.ScaResults.OperationalRisk) + } + if ss.IacResults != nil { + summary = MergeResultSummaries(summary, *ss.IacResults) } - return total + if ss.SecretsResults != nil { + summary = MergeResultSummaries(summary, *ss.SecretsResults) + } + if ss.SastResults != nil { + summary = MergeResultSummaries(summary, *ss.SastResults) + } + return } -// Severity -> Applicable status -> Count -type TwoLevelSummaryCount map[string]SummaryCount +func GetViolationSummaries(summaries ...ResultsSummary) *ScanViolationsSummary { + if len(summaries) == 0 { + return nil + } + violationsSummary := &ScanViolationsSummary{} + watches := datastructures.MakeSet[string]() + failBuild := false + foundViolations := false + for _, summary := range summaries { + for i := range summary.Scans { + if summary.Scans[i].Violations == nil { + continue + } + foundViolations = true + watches.AddElements(summary.Scans[i].Violations.Watches...) + failBuild = failBuild || summary.Scans[i].Violations.FailBuild + extractIssuesToSummary(&summary.Scans[i].Violations.ScanResultSummary, &violationsSummary.ScanResultSummary) + } + } + if !foundViolations { + return nil + } + violationsSummary.Watches = watches.ToSlice() + violationsSummary.FailBuild = failBuild + return violationsSummary +} -func (sc TwoLevelSummaryCount) GetTotal() (total int) { - for _, count := range sc { - total += count.GetTotal() +func GetVulnerabilitiesSummaries(summaries ...ResultsSummary) *ScanResultSummary { + if len(summaries) == 0 { + return nil } - return + vulnerabilitiesSummary := &ScanResultSummary{} + foundVulnerabilities := false + for _, summary := range summaries { + for i := range summary.Scans { + if summary.Scans[i].Vulnerabilities == nil { + continue + } + foundVulnerabilities = true + extractIssuesToSummary(summary.Scans[i].Vulnerabilities, vulnerabilitiesSummary) + } + } + if !foundVulnerabilities { + return nil + } + return vulnerabilitiesSummary } -func (sc TwoLevelSummaryCount) GetCombinedLowerLevel() (oneLvlCounts SummaryCount) { - oneLvlCounts = SummaryCount{} - for firstLvl, secondLvl := range sc { - for _, count := range secondLvl { - oneLvlCounts[firstLvl] += count +func extractIssuesToSummary(issues *ScanResultSummary, destination *ScanResultSummary) { + if issues.ScaResults != nil { + if destination.ScaResults == nil { + destination.ScaResults = &ScaScanResultSummary{} + } + destination.ScaResults.ScanIds = append(destination.ScaResults.ScanIds, issues.ScaResults.ScanIds...) + destination.ScaResults.MoreInfoUrls = append(destination.ScaResults.MoreInfoUrls, issues.ScaResults.MoreInfoUrls...) + if issues.ScaResults.Security.GetTotal() > 0 { + destination.ScaResults.Security = MergeResultSummaries(destination.ScaResults.Security, issues.ScaResults.Security) + } + if issues.ScaResults.License.GetTotal() > 0 { + destination.ScaResults.License = MergeResultSummaries(destination.ScaResults.License, issues.ScaResults.License) + } + if issues.ScaResults.OperationalRisk.GetTotal() > 0 { + destination.ScaResults.OperationalRisk = MergeResultSummaries(destination.ScaResults.OperationalRisk, issues.ScaResults.OperationalRisk) } } - return + if issues.IacResults != nil { + destination.IacResults = mergeResultSummariesPointers(destination.IacResults, issues.IacResults) + } + if issues.SecretsResults != nil { + destination.SecretsResults = mergeResultSummariesPointers(destination.SecretsResults, issues.SecretsResults) + } + if issues.SastResults != nil { + destination.SastResults = mergeResultSummariesPointers(destination.SastResults, issues.SastResults) + } } -func (sc TwoLevelSummaryCount) GetCountOfKeys(firstLevel bool) int { - if firstLevel { - return len(sc) +func mergeResultSummariesPointers(summaries ...*ResultSummary) (merged *ResultSummary) { + toMerge := []ResultSummary{} + for _, summary := range summaries { + if summary != nil { + toMerge = append(toMerge, *summary) + } } - count := datastructures.MakeSet[string]() - for _, value := range sc { - for key := range value { - count.Add(key) + result := MergeResultSummaries(toMerge...) + return &result +} + +func MergeResultSummaries(summaries ...ResultSummary) (merged ResultSummary) { + merged = ResultSummary{} + for _, summary := range summaries { + for severity, statusCount := range summary { + if _, ok := merged[severity]; !ok { + merged[severity] = statusCount + } else { + for status, count := range statusCount { + merged[severity][status] += count + } + } } } - return count.Size() + return } diff --git a/formats/summary_test.go b/formats/summary_test.go index 0e36894c..df701bed 100644 --- a/formats/summary_test.go +++ b/formats/summary_test.go @@ -1,199 +1,413 @@ package formats import ( + "sort" "testing" "github.com/stretchr/testify/assert" ) -func TestSummaryCount(t *testing.T) { +func TestCuratedPackages(t *testing.T) { testCases := []struct { - name string - count SummaryCount - expected int + name string + curatedPackages CuratedPackages + expectedApproved int + expectedBlocked int }{ - {"Empty", SummaryCount{}, 0}, - {"Single", SummaryCount{"High": 1}, 1}, - {"Multiple", SummaryCount{"High": 1, "Medium": 2, "Low": 3}, 6}, + {"Empty", CuratedPackages{}, 0, 0}, + {"Approved", CuratedPackages{PackageCount: 1}, 1, 0}, + {"Blocked", CuratedPackages{Blocked: []BlockedPackages{{Policy: "Test", Condition: "Test condition", Packages: map[string]int{"npm://test:1.0.0": 1}}}, PackageCount: 1}, 0, 1}, + { + "Multiple", + CuratedPackages{ + Blocked: []BlockedPackages{ + { + Policy: "Test", + Condition: "Test condition", + Packages: map[string]int{"npm://test:1.0.0": 1, "npm://test3:1.0.0": 1}, + }, + { + Policy: "Test2", + Condition: "Test condition 2", + Packages: map[string]int{"npm://test2:1.0.0": 1, "npm://test3:1.0.0": 1}, + }, + }, + PackageCount: 9, + }, + 6, 3, + }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.expected, testCase.count.GetTotal()) + assert.Equal(t, testCase.expectedApproved, testCase.curatedPackages.GetApprovedCount()) + assert.Equal(t, testCase.expectedBlocked, testCase.curatedPackages.GetBlockedCount()) }) } } -func TestTwoLevelSummaryCount(t *testing.T) { +func TestResultSummary(t *testing.T) { + testSummary := ResultSummary{ + "High": map[string]int{NoStatus: 1, "Status1": 2}, + "Medium": map[string]int{"Status1": 3, "Status2": 4}, + "Low": map[string]int{NoStatus: 15}, + "Unknown": map[string]int{"Status2": 6}, + } testCases := []struct { - name string - count TwoLevelSummaryCount - expected int - expectedSeverityCountsWithoutStatus SummaryCount + name string + summary ResultSummary + severityFilters []string + expectedTotal int }{ - {"Empty", TwoLevelSummaryCount{}, 0, SummaryCount{}}, - {"Single-NoStatus", TwoLevelSummaryCount{"High": SummaryCount{"": 1}}, 1, SummaryCount{"High": 1}}, - {"Single-Status", TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 1}}, 1, SummaryCount{"High": 1}}, { - "Multiple-NoStatus", - TwoLevelSummaryCount{"High": SummaryCount{"": 1}, "Medium": SummaryCount{"": 2}, "Low": SummaryCount{"": 3}}, - 6, - SummaryCount{"High": 1, "Medium": 2, "Low": 3}, + name: "Empty", + summary: ResultSummary{}, + expectedTotal: 0, }, { - "Multiple-Status", - TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 1}, "Medium": SummaryCount{"": 2}, "Low": SummaryCount{"Applicable": 3, "Not Applicable": 3}}, - 9, - SummaryCount{"High": 1, "Medium": 2, "Low": 6}, + name: "No filters", + summary: testSummary, + expectedTotal: 31, + }, + { + name: "With filters", + summary: testSummary, + severityFilters: []string{"Critical", "High", "Low"}, + expectedTotal: 18, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.expected, testCase.count.GetTotal()) - assert.Equal(t, testCase.expectedSeverityCountsWithoutStatus, testCase.count.GetCombinedLowerLevel()) + assert.Equal(t, testCase.expectedTotal, testCase.summary.GetTotal(testCase.severityFilters...)) }) } } -func TestScanVulnerabilitiesSummary(t *testing.T) { +func TestScanResultSummary(t *testing.T) { + ids := []string{"id1", "id2"} + urls := []string{"url1", "url2"} + testSummary := ScanResultSummary{ + ScaResults: &ScaScanResultSummary{ + ScanIds: ids, + MoreInfoUrls: urls, + Security: ResultSummary{"Critical": map[string]int{"Status": 1}, "High": map[string]int{NoStatus: 1}}, + License: ResultSummary{"High": map[string]int{NoStatus: 1}}, + OperationalRisk: ResultSummary{"Low": map[string]int{NoStatus: 1}}, + }, + SecretsResults: &ResultSummary{"Medium": map[string]int{NoStatus: 1}}, + SastResults: &ResultSummary{"High": map[string]int{"Status": 1}}, + } testCases := []struct { - name string - summary *ScanVulnerabilitiesSummary - expectedTotalIssueCount int - expectedTotalUniqueIssueCount int - expectedSubScansWithIssues []SummarySubScanType - expectedSubScansIssuesCount map[SummarySubScanType]int + name string + summary ScanResultSummary + resultTypeFilters []SummaryResultType + expectedTotal int + expectedScanIds []string + expectedMoreInfoUrls []string }{ { - "Empty", - &ScanVulnerabilitiesSummary{}, - 0, 0, - []SummarySubScanType{}, - map[SummarySubScanType]int{}, + name: "No Issues", + summary: ScanResultSummary{ScaResults: &ScaScanResultSummary{}, SecretsResults: &ResultSummary{}}, }, { - "Single", - &ScanVulnerabilitiesSummary{ - ScaScanResults: &ScanScaResult{ - SummaryCount: TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 1}}, - UniqueFindings: 1, - }, - }, - 1, 1, - []SummarySubScanType{ScaScan}, - map[SummarySubScanType]int{ScaScan: 1}, + name: "No filters", + summary: testSummary, + expectedTotal: 6, + expectedScanIds: ids, + expectedMoreInfoUrls: urls, }, { - "Multiple", - &ScanVulnerabilitiesSummary{ - ScaScanResults: &ScanScaResult{ - SummaryCount: TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 2}}, - UniqueFindings: 1, - }, - SastScanResults: &SummaryCount{"High": 1}, - }, - 3, 2, - []SummarySubScanType{SastScan, ScaScan}, - map[SummarySubScanType]int{SastScan: 1, ScaScan: 2}, + name: "One filter", + summary: testSummary, + resultTypeFilters: []SummaryResultType{ScaSecurityResult}, + expectedTotal: 2, + expectedScanIds: ids, + expectedMoreInfoUrls: urls, + }, + { + name: "Multiple filters", + summary: testSummary, + resultTypeFilters: []SummaryResultType{ScaSecurityResult, ScaLicenseResult, IacResult, SecretsResult, SastResult}, + expectedTotal: 5, + expectedScanIds: ids, + expectedMoreInfoUrls: urls, }, } + for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - validateScanVulnerabilitiesSummary(t, testCase.summary, testCase.expectedTotalIssueCount, testCase.expectedTotalUniqueIssueCount, testCase.expectedSubScansWithIssues, testCase.expectedSubScansIssuesCount) + assert.Equal(t, testCase.expectedTotal, testCase.summary.GetTotal(testCase.resultTypeFilters...)) + assert.Equal(t, testCase.expectedTotal > 0, testCase.summary.HasIssues()) + assert.ElementsMatch(t, testCase.expectedScanIds, testCase.summary.GetScanIds()) + assert.ElementsMatch(t, testCase.expectedMoreInfoUrls, testCase.summary.GetMoreInfoUrls()) }) } } -func validateScanVulnerabilitiesSummary(t *testing.T, summary *ScanVulnerabilitiesSummary, expectedTotalIssueCount, expectedTotalUniqueIssueCount int, expectedSubScansWithIssues []SummarySubScanType, expectedSubScansIssuesCount map[SummarySubScanType]int) { - assert.Equal(t, expectedTotalIssueCount, summary.GetTotalIssueCount()) - assert.Equal(t, expectedTotalUniqueIssueCount, summary.GetTotalUniqueIssueCount()) - if assert.Equal(t, expectedSubScansWithIssues, summary.GetSubScansWithIssues()) { - for subScan, expectedCount := range expectedSubScansIssuesCount { - assert.Equal(t, expectedCount, summary.GetSubScanTotalIssueCount(subScan)) - } +func TestScanSummary(t *testing.T) { + curatedBlocked := &CuratedPackages{Blocked: []BlockedPackages{{Packages: map[string]int{"npm://test:1.0.0": 1}}}, PackageCount: 1} + vulnerabilities := &ScanResultSummary{SecretsResults: &ResultSummary{"High": map[string]int{"Status": 1}}} + violations := &ScanViolationsSummary{ScanResultSummary: *vulnerabilities, Watches: []string{"watch1", "watch2"}} + testCases := []struct { + name string + summary ScanSummary + expectedHasCuratedPackages bool + expectedHasBlockedPackages bool + expectedHasViolations bool + expectedHasVulnerabilities bool + }{ + { + name: "Empty", + summary: ScanSummary{}, + }, + { + name: "CuratedPackages", + summary: ScanSummary{CuratedPackages: &CuratedPackages{PackageCount: 1}, Vulnerabilities: &ScanResultSummary{}}, + expectedHasCuratedPackages: true, + }, + { + name: "BlockedPackages", + summary: ScanSummary{CuratedPackages: curatedBlocked, Violations: &ScanViolationsSummary{}}, + expectedHasCuratedPackages: true, + expectedHasBlockedPackages: true, + }, + { + name: "Vulnerabilities", + summary: ScanSummary{Vulnerabilities: vulnerabilities}, + expectedHasVulnerabilities: true, + }, + { + name: "Violations", + summary: ScanSummary{Violations: violations}, + expectedHasViolations: true, + }, + { + name: "All", + summary: ScanSummary{CuratedPackages: curatedBlocked, Vulnerabilities: vulnerabilities, Violations: violations}, + expectedHasCuratedPackages: true, + expectedHasBlockedPackages: true, + expectedHasVulnerabilities: true, + expectedHasViolations: true, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.expectedHasCuratedPackages, testCase.summary.HasCuratedPackages()) + assert.Equal(t, testCase.expectedHasBlockedPackages, testCase.summary.HasBlockedPackages()) + assert.Equal(t, testCase.expectedHasViolations, testCase.summary.HasViolations()) + assert.Equal(t, testCase.expectedHasVulnerabilities, testCase.summary.HasVulnerabilities()) + }) } } -func validateViolationSummary(t *testing.T, summary TwoLevelSummaryCount, expectedTotalIssueCount int, expectedViolationTypeCount map[ViolationIssueType]int) { - assert.Equal(t, expectedTotalIssueCount, summary.GetTotal()) - for violationType, expectedCount := range expectedViolationTypeCount { - assert.Equal(t, expectedCount, summary[violationType.String()].GetTotal()) +func TestResultsSummary(t *testing.T) { + testScans := []ScanSummary{ + {Vulnerabilities: &ScanResultSummary{SecretsResults: &ResultSummary{"High": map[string]int{"Status": 4}}}}, + {Violations: &ScanViolationsSummary{ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{License: ResultSummary{"Medium": map[string]int{NoStatus: 2}}}, SastResults: &ResultSummary{"High": map[string]int{"Status": 1}}}}}, + {Vulnerabilities: &ScanResultSummary{SastResults: &ResultSummary{"Medium": map[string]int{NoStatus: 1}}}}, + {Vulnerabilities: &ScanResultSummary{ScaResults: &ScaScanResultSummary{Security: ResultSummary{"Critical": map[string]int{"Status": 3}}}}}, + {Vulnerabilities: &ScanResultSummary{ScaResults: &ScaScanResultSummary{Security: ResultSummary{"High": map[string]int{NoStatus: 1}, "Low": map[string]int{NoStatus: 1}}}}}, + } + testCases := []struct { + name string + summary ResultsSummary + filters []SummaryResultType + expectedTotalVulnerabilities int + expectedTotalViolations int + }{ + { + name: "Empty", + summary: ResultsSummary{}, + }, + { + name: "No filters", + summary: ResultsSummary{Scans: testScans}, + expectedTotalVulnerabilities: 10, + expectedTotalViolations: 3, + }, + { + name: "With filters", + summary: ResultsSummary{Scans: testScans}, + filters: []SummaryResultType{ScaLicenseResult, IacResult, SecretsResult}, + expectedTotalVulnerabilities: 4, + expectedTotalViolations: 2, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.expectedTotalVulnerabilities, testCase.summary.GetTotalVulnerabilities(testCase.filters...)) + assert.Equal(t, testCase.expectedTotalViolations, testCase.summary.GetTotalViolations(testCase.filters...)) + assert.Equal(t, testCase.expectedTotalViolations > 0, testCase.summary.HasViolations()) + }) } } -func TestScanSummaryResult(t *testing.T) { +func TestGetVulnerabilitiesSummaries(t *testing.T) { + dummyScaResults := &ScaScanResultSummary{Security: ResultSummary{"High": map[string]int{NoStatus: 1}}} + dummyResultSummary := &ResultSummary{"Medium": map[string]int{NoStatus: 1}} testCases := []struct { - name string - result *ScanSummaryResult - - expectedTotalIssueCount int - expectedTotalVulnerabilityCount int - expectedTotalViolationCount int + name string + input []ResultsSummary + expectedShowVulnerabilities bool + expectedVulnerabilitiesSummaries *ScanResultSummary + }{ + { + name: "Vulnerabilities not requested", + input: []ResultsSummary{}, + }, + { + name: "No Vulnerabilities", + expectedShowVulnerabilities: true, + input: []ResultsSummary{{Scans: []ScanSummary{{Target: "target", Vulnerabilities: &ScanResultSummary{}}}}}, + expectedVulnerabilitiesSummaries: &ScanResultSummary{}, + }, + { + name: "Single input", + expectedShowVulnerabilities: true, + input: []ResultsSummary{{Scans: []ScanSummary{{Target: "target", Vulnerabilities: &ScanResultSummary{ScaResults: dummyScaResults, SecretsResults: dummyResultSummary}}}}}, + expectedVulnerabilitiesSummaries: &ScanResultSummary{ScaResults: dummyScaResults, SecretsResults: dummyResultSummary}, + }, + { + name: "Multiple inputs", + expectedShowVulnerabilities: true, + input: []ResultsSummary{ + {Scans: []ScanSummary{{Target: "target1", Vulnerabilities: &ScanResultSummary{ScaResults: dummyScaResults}}}}, + { + Scans: []ScanSummary{ + {Target: "target2", Vulnerabilities: &ScanResultSummary{SecretsResults: dummyResultSummary}}, + {Target: "target3", Vulnerabilities: &ScanResultSummary{SastResults: dummyResultSummary}}, + }, + }, + }, + expectedVulnerabilitiesSummaries: &ScanResultSummary{ScaResults: dummyScaResults, SecretsResults: dummyResultSummary, SastResults: dummyResultSummary}, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + vulnerabilitiesSummaries := GetVulnerabilitiesSummaries(testCase.input...) + if testCase.expectedShowVulnerabilities { + assert.Equal(t, testCase.expectedVulnerabilitiesSummaries, vulnerabilitiesSummaries) + } else { + assert.Nil(t, vulnerabilitiesSummaries) + } + }) + } +} - expectedSubScansWithIssues []SummarySubScanType - expectedSubScansIssuesCount map[SummarySubScanType]int - expectedViolationTypeCount map[ViolationIssueType]int +func TestGetViolationSummaries(t *testing.T) { + testCases := []struct { + name string + input []ResultsSummary + expectedShowViolations bool + expectedViolationSummaries *ScanViolationsSummary }{ { - "Empty", - &ScanSummaryResult{}, - 0, 0, 0, - []SummarySubScanType{}, - map[SummarySubScanType]int{}, - map[ViolationIssueType]int{}, + name: "violation context not defined", + input: []ResultsSummary{}, }, { - "Single", - &ScanSummaryResult{ - Vulnerabilities: &ScanVulnerabilitiesSummary{ - ScaScanResults: &ScanScaResult{ - SummaryCount: TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 1}}, - UniqueFindings: 1, + name: "No Violations", + expectedShowViolations: true, + input: []ResultsSummary{{Scans: []ScanSummary{{Target: "target", Violations: &ScanViolationsSummary{}}}}}, + expectedViolationSummaries: &ScanViolationsSummary{}, + }, + { + name: "Single input", + expectedShowViolations: true, + input: []ResultsSummary{{Scans: []ScanSummary{ + { + Target: "target1", + Violations: &ScanViolationsSummary{ + Watches: []string{"watch1"}, + FailBuild: true, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"Critical": map[string]int{NoStatus: 1}}, + OperationalRisk: ResultSummary{"Low": map[string]int{NoStatus: 1}}, + }}, }, }, + { + Target: "target2", + Violations: &ScanViolationsSummary{ + Watches: []string{"watch2"}, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"High": map[string]int{NoStatus: 1}}, + License: ResultSummary{"High": map[string]int{NoStatus: 1}}, + }}, + }, + }, + }}}, + expectedViolationSummaries: &ScanViolationsSummary{ + Watches: []string{"watch1", "watch2"}, + FailBuild: true, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"Critical": map[string]int{NoStatus: 1}, "High": map[string]int{NoStatus: 1}}, + License: ResultSummary{"High": map[string]int{NoStatus: 1}}, + OperationalRisk: ResultSummary{"Low": map[string]int{NoStatus: 1}}, + }}, }, - 1, 1, 0, - []SummarySubScanType{ScaScan}, - map[SummarySubScanType]int{ScaScan: 1}, - map[ViolationIssueType]int{}, }, { - "Multiple", - &ScanSummaryResult{ - Vulnerabilities: &ScanVulnerabilitiesSummary{ - ScaScanResults: &ScanScaResult{ - SummaryCount: TwoLevelSummaryCount{"High": SummaryCount{"Applicable": 1}}, - UniqueFindings: 1, + name: "Multiple inputs", + expectedShowViolations: true, + input: []ResultsSummary{ + { + Scans: []ScanSummary{ + { + Target: "target1", + Violations: &ScanViolationsSummary{ + Watches: []string{"watch1"}, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"Critical": map[string]int{NoStatus: 1}}, + OperationalRisk: ResultSummary{"Low": map[string]int{NoStatus: 1}}, + }}, + }, + }, + { + Target: "target2", + Violations: &ScanViolationsSummary{ + Watches: []string{"watch2"}, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"High": map[string]int{NoStatus: 1}}, + License: ResultSummary{"High": map[string]int{NoStatus: 1}}, + }}, + }, + }, }, - SastScanResults: &SummaryCount{"High": 1}, }, - Violations: TwoLevelSummaryCount{ - ViolationTypeSecurity.String(): {"High": 1}, - ViolationTypeLicense.String(): {"High": 1}, - ViolationTypeOperationalRisk.String(): {"High": 1}, + { + Scans: []ScanSummary{ + { + Target: "target3", + Violations: &ScanViolationsSummary{ + Watches: []string{"watch1"}, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"Critical": map[string]int{NoStatus: 1, "status": 2}, "High": map[string]int{NoStatus: 1}}, + License: ResultSummary{"Medium": map[string]int{NoStatus: 1}}, + }}, + }, + }, + }, }, }, - 5, 2, 3, - []SummarySubScanType{SastScan, ScaScan}, - map[SummarySubScanType]int{SastScan: 1, ScaScan: 1}, - map[ViolationIssueType]int{ViolationTypeSecurity: 1, ViolationTypeLicense: 1, ViolationTypeOperationalRisk: 1}, + expectedViolationSummaries: &ScanViolationsSummary{ + Watches: []string{"watch1", "watch2"}, + ScanResultSummary: ScanResultSummary{ScaResults: &ScaScanResultSummary{ + Security: ResultSummary{"Critical": map[string]int{NoStatus: 2, "status": 2}, "High": map[string]int{NoStatus: 2}}, + License: ResultSummary{"High": map[string]int{NoStatus: 1}, "Medium": map[string]int{NoStatus: 1}}, + OperationalRisk: ResultSummary{"Low": map[string]int{NoStatus: 1}}, + }}, + }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - // validate general - assert.Equal(t, testCase.expectedTotalIssueCount > 0, testCase.result.HasIssues()) - assert.Equal(t, testCase.expectedTotalIssueCount, testCase.result.GetTotalIssueCount()) - assert.Equal(t, testCase.expectedTotalViolationCount > 0, testCase.result.HasViolations()) - assert.Equal(t, testCase.expectedTotalViolationCount, testCase.result.GetTotalViolationCount()) - - assert.Equal(t, testCase.expectedTotalVulnerabilityCount > 0, testCase.result.HasSecurityVulnerabilities()) - - // validate content - if testCase.result.Vulnerabilities != nil { - validateScanVulnerabilitiesSummary(t, testCase.result.Vulnerabilities, testCase.expectedTotalVulnerabilityCount, testCase.expectedTotalVulnerabilityCount, testCase.expectedSubScansWithIssues, testCase.expectedSubScansIssuesCount) + violationSummaries := GetViolationSummaries(testCase.input...) + if testCase.expectedShowViolations { + sort.Strings(violationSummaries.Watches) + assert.Equal(t, testCase.expectedViolationSummaries, violationSummaries) + } else { + assert.Nil(t, violationSummaries) } - validateViolationSummary(t, testCase.result.Violations, testCase.expectedTotalViolationCount, testCase.expectedViolationTypeCount) }) } - } diff --git a/go.mod b/go.mod index ddd0a3e7..42caa94e 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,17 @@ require ( github.com/beevik/etree v1.4.0 github.com/google/go-github/v56 v56.0.0 github.com/gookit/color v1.5.4 - github.com/jfrog/build-info-go v1.9.34 + github.com/jfrog/build-info-go v1.9.35 github.com/jfrog/froggit-go v1.16.1 github.com/jfrog/gofrog v1.7.5 github.com/jfrog/jfrog-apps-config v1.0.1 - github.com/jfrog/jfrog-cli-core/v2 v2.55.2 - github.com/jfrog/jfrog-client-go v1.44.2 + github.com/jfrog/jfrog-cli-core/v2 v2.55.6 + github.com/jfrog/jfrog-client-go v1.46.1 github.com/magiconair/properties v1.8.7 github.com/owenrumney/go-sarif/v2 v2.3.0 github.com/stretchr/testify v1.9.0 github.com/urfave/cli v1.22.15 - golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/sync v0.8.0 golang.org/x/text v0.17.0 gopkg.in/yaml.v3 v3.0.1 @@ -93,7 +93,7 @@ require ( github.com/spf13/viper v1.19.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/ulikunitz/xz v0.5.12 // indirect - github.com/vbauerster/mpb/v8 v8.7.5 // indirect + github.com/vbauerster/mpb/v8 v8.8.3 // indirect github.com/xanzy/go-gitlab v0.95.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect @@ -104,7 +104,7 @@ require ( golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect - golang.org/x/sys v0.23.0 // indirect + golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.24.0 // indirect diff --git a/go.sum b/go.sum index 77d8088e..854d47fa 100644 --- a/go.sum +++ b/go.sum @@ -890,18 +890,18 @@ github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+ github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/jfrog/archiver/v3 v3.6.1 h1:LOxnkw9pOn45DzCbZNFV6K0+6dCsQ0L8mR3ZcujO5eI= github.com/jfrog/archiver/v3 v3.6.1/go.mod h1:VgR+3WZS4N+i9FaDwLZbq+jeU4B4zctXL+gL4EMzfLw= -github.com/jfrog/build-info-go v1.9.34 h1:bPnW58VpclbpBe/x8XEu/2BIviEOoJrJ5PkRRcmU3Co= -github.com/jfrog/build-info-go v1.9.34/go.mod h1:6mdtqjREK76bHNODXakqKR/+ksJ9dvfLS7H57BZtnLY= +github.com/jfrog/build-info-go v1.9.35 h1:P53Ckbuin0GYrq0LWMY0GZSptJcQwiUyW6lqTbXKdcc= +github.com/jfrog/build-info-go v1.9.35/go.mod h1:6mdtqjREK76bHNODXakqKR/+ksJ9dvfLS7H57BZtnLY= github.com/jfrog/froggit-go v1.16.1 h1:FBIM1qevX/ag9unfmpGzfmZ36D8ulOJ+DPTSFUk3l5U= github.com/jfrog/froggit-go v1.16.1/go.mod h1:TEJSzgiV+3D/GVGE8Y6j46ut1jrBLD1FL6WdMdKwwCE= github.com/jfrog/gofrog v1.7.5 h1:dFgtEDefJdlq9cqTRoe09RLxS5Bxbe1Ev5+E6SmZHcg= github.com/jfrog/gofrog v1.7.5/go.mod h1:jyGiCgiqSSR7k86hcUSu67XVvmvkkgWTmPsH25wI298= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-core/v2 v2.55.2 h1:Pm4mY1UThSyFGklDl6O8qoJgTgH9jL3i2tor/ux+X8c= -github.com/jfrog/jfrog-cli-core/v2 v2.55.2/go.mod h1:2/Ccqq0ayMqIuH5AAoneX0CowwdrNWQcs5aKz8iDYkE= -github.com/jfrog/jfrog-client-go v1.44.2 h1:5t8tx6NOth6Xq24SdF3MYSd6vo0bTibW93nads2DEuY= -github.com/jfrog/jfrog-client-go v1.44.2/go.mod h1:f5Jfv+RGKVr4smOp4a4pxyBKdlpLG7R894kx2XW+w8c= +github.com/jfrog/jfrog-cli-core/v2 v2.55.6 h1:3tQuEdYgS2q7fkrrSG66OnO0S998FXGaY9BVsxSLst4= +github.com/jfrog/jfrog-cli-core/v2 v2.55.6/go.mod h1:DPO5BfWAeOByahFMMy+PcjmbPlcyoRy7Bf2C5sGKVi0= +github.com/jfrog/jfrog-client-go v1.46.1 h1:ExqOF8ClOG9LO3vbm6jTIwQHHhprbu8lxB2RrM6mMI0= +github.com/jfrog/jfrog-client-go v1.46.1/go.mod h1:UCu2JNBfMp9rypEmCL84DCooG79xWIHVadZQR3Ab+BQ= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= @@ -1064,8 +1064,8 @@ github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= -github.com/vbauerster/mpb/v8 v8.7.5 h1:hUF3zaNsuaBBwzEFoCvfuX3cpesQXZC0Phm/JcHZQ+c= -github.com/vbauerster/mpb/v8 v8.7.5/go.mod h1:bRCnR7K+mj5WXKsy0NWB6Or+wctYGvVwKn6huwvxKa0= +github.com/vbauerster/mpb/v8 v8.8.3 h1:dTOByGoqwaTJYPubhVz3lO5O6MK553XVgUo33LdnNsQ= +github.com/vbauerster/mpb/v8 v8.8.3/go.mod h1:JfCCrtcMsJwP6ZwMn9e5LMnNyp3TVNpUWWkN+nd4EWk= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/xanzy/go-gitlab v0.95.2 h1:4p0IirHqEp5f0baK/aQqr4TR57IsD+8e4fuyAA1yi88= @@ -1140,8 +1140,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= -golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -1392,8 +1392,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/resources/resources.go b/resources/resources.go new file mode 100644 index 00000000..d1e5931a --- /dev/null +++ b/resources/resources.go @@ -0,0 +1,3 @@ +package resources + +const BaseResourcesUrl = "https://raw.githubusercontent.com/jfrog/jfrog-cli-security/main/resources" diff --git a/resources/statusIcons/failed.svg b/resources/statusIcons/failed.svg new file mode 100644 index 00000000..bbd5646a --- /dev/null +++ b/resources/statusIcons/failed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/statusIcons/passed.svg b/resources/statusIcons/passed.svg new file mode 100644 index 00000000..34a8778c --- /dev/null +++ b/resources/statusIcons/passed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/testdata/other/jobSummary/binary_vulnerabilities.md b/tests/testdata/other/jobSummary/binary_vulnerabilities.md new file mode 100644 index 00000000..2e2b6762 --- /dev/null +++ b/tests/testdata/other/jobSummary/binary_vulnerabilities.md @@ -0,0 +1 @@ +
44 Security issues are grouped by CVE number:	44 SCA

ā—ļø 33 Critical

šŸŸ” 11 Low

See the results of the scan in JFrog
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/build_scan_vulnerabilities.md b/tests/testdata/other/jobSummary/build_scan_vulnerabilities.md new file mode 100644 index 00000000..ae008afc --- /dev/null +++ b/tests/testdata/other/jobSummary/build_scan_vulnerabilities.md @@ -0,0 +1 @@ +
24 Security Issues:	24 SCA

šŸ”“ 3 High

šŸŸ  1 Medium

āšŖļø 20 Unknown

See the results of the scan in JFrog
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/docker_vulnerabilities.md b/tests/testdata/other/jobSummary/docker_vulnerabilities.md new file mode 100644 index 00000000..399269f7 --- /dev/null +++ b/tests/testdata/other/jobSummary/docker_vulnerabilities.md @@ -0,0 +1 @@ +
20 Security issues are grouped by CVE number:	17 SCA	3 Secrets

ā—ļø 8 Critical (2 Not Applicable)

šŸ”“ 5 High

šŸŸ  3 Medium

šŸŸ” 3 Low (3 Not Applicable)

āšŖļø 1 Unknown

See the results of the scan in JFrog
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/multi_command_job.md b/tests/testdata/other/jobSummary/multi_command_job.md deleted file mode 100644 index f383ea6b..00000000 --- a/tests/testdata/other/jobSummary/multi_command_job.md +++ /dev/null @@ -1,21 +0,0 @@ -#### Builds -| Status | Id | Details | -|--------|----|---------| -| āœ… | build-name (build-number) | | -| āŒ | build-name (build-number) |
Violations: 4 - (2 Security, 1 License, 1 Operational)
| -#### Artifacts -| Status | Id | Details | -|--------|----|---------| -| āŒ | /binary-name |
Security Vulnerabilities: 3 (3 unique)
ā””ā”€ā”€ 3 Secrets šŸ”“ 2 High
šŸŸ” 1 Low
| -| āœ… | other-root/dir/binary-name2 | | -#### Modules -| Status | Id | Details | -|--------|----|---------| -| āŒ | /application1 |
Security Vulnerabilities: 14 (12 unique)
ā”œā”€ā”€ 1 SAST šŸŸ” 1 Low
ā”œā”€ā”€ 5 IAC šŸŸ  5 Medium
ā””ā”€ā”€ 8 SCA ā—ļø 3 Critical (2 Not Applicable)
šŸ”“ 4 High (1 Applicable, 1 Not Applicable)
šŸŸ” 1 Low
| -| āŒ | /application2 |
Violations: 1 - (1 Security)
Security Vulnerabilities: 1 (1 unique)
ā””ā”€ā”€ 1 SCA šŸ”“ 1 High (1 Not Applicable)
| -| āœ… | /dir/application3 | | -#### Curation -| Status | Id | Details | -|--------|----|---------| -| āŒ | /application1 |
Total Number of Packages: 6
šŸŸ¢ Total Number of Approved Packages: 4
šŸ”“ Total Number of Blocked Packages: 2
ā”œā”€ā”€ Policy: cvss_score, Condition:cvss score higher than 4.0 (1)
ā””ā”€ā”€ Policy: Malicious, Condition: Malicious package (1)
| -| āŒ | /application2 |
Total Number of Packages: 6
šŸŸ¢ Total Number of Approved Packages: 4
šŸ”“ Total Number of Blocked Packages: 2
ā”œā”€ā”€ Policy: License, Condition: GPL (1)
ā””ā”€ā”€ Policy: Aged, Condition: Package is aged (1)
| \ No newline at end of file diff --git a/tests/testdata/other/jobSummary/no_violations.md b/tests/testdata/other/jobSummary/no_violations.md new file mode 100644 index 00000000..89f2b3bc --- /dev/null +++ b/tests/testdata/other/jobSummary/no_violations.md @@ -0,0 +1 @@ +
No violations found
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/no_vulnerabilities.md b/tests/testdata/other/jobSummary/no_vulnerabilities.md new file mode 100644 index 00000000..e724deff --- /dev/null +++ b/tests/testdata/other/jobSummary/no_vulnerabilities.md @@ -0,0 +1 @@ +
No security issues found
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/security_section.md b/tests/testdata/other/jobSummary/security_section.md new file mode 100644 index 00000000..517122ca --- /dev/null +++ b/tests/testdata/other/jobSummary/security_section.md @@ -0,0 +1,8 @@ +

šŸ”’ Security Summary

+ +#### Curation Audit +| Audit Summary | Project name | Audit Details | +|--------|--------|---------| +| failed.svg | /application1 |
Total Number of resolved packages: 6
šŸŸ¢ Approved packages: 3
šŸ”“ Blocked packages: 3
Violated Policy: cvss_score, Condition: cvss score higher than 4.0 (2)šŸ“¦ npm://test:2.0.0
šŸ“¦ npm://underscore:1.0.0
Violated Policy: Malicious, Condition: Malicious package (1)šŸ“¦ npm://lodash:1.0.0
| +| passed.svg | /application2 |
Total Number of resolved packages: 3
| +| failed.svg | /application3 |
Total Number of resolved packages: 5
šŸŸ¢ Approved packages: 4
šŸ”“ Blocked packages: 1
Violated Policy: Aged, Condition: Package is aged (1)šŸ“¦ npm://test:1.0.0
|
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/single_issue.md b/tests/testdata/other/jobSummary/single_issue.md deleted file mode 100644 index e2de0159..00000000 --- a/tests/testdata/other/jobSummary/single_issue.md +++ /dev/null @@ -1 +0,0 @@ -
Violations: 1 - (1 License)
Security Vulnerabilities: 3 (3 unique)
ā””ā”€ā”€ 3 Secrets šŸ”“ 2 High
šŸŸ” 1 Low
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/single_no_issue.md b/tests/testdata/other/jobSummary/single_no_issue.md deleted file mode 100644 index 1a19cc7a..00000000 --- a/tests/testdata/other/jobSummary/single_no_issue.md +++ /dev/null @@ -1,3 +0,0 @@ -``` -āœ… No issues were found -``` \ No newline at end of file diff --git a/tests/testdata/other/jobSummary/violations.md b/tests/testdata/other/jobSummary/violations.md new file mode 100644 index 00000000..f604e7e6 --- /dev/null +++ b/tests/testdata/other/jobSummary/violations.md @@ -0,0 +1 @@ +
watches: 
watch1, watch2, watch3, watch4
watch5

23 Policy Violations:	17 Security	2 Operational	1 License	3 Secrets

ā—ļø 8 Critical (2 Not Applicable)

šŸ”“ 6 High

šŸŸ  3 Medium

šŸŸ” 5 Low (3 Not Applicable)

āšŖļø 1 Unknown

See the results of the scan in JFrog
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/violations_not_defined.md b/tests/testdata/other/jobSummary/violations_not_defined.md new file mode 100644 index 00000000..dd8c130d --- /dev/null +++ b/tests/testdata/other/jobSummary/violations_not_defined.md @@ -0,0 +1 @@ +
No watch is defined
\ No newline at end of file diff --git a/tests/testdata/other/jobSummary/violations_not_extended_view.md b/tests/testdata/other/jobSummary/violations_not_extended_view.md new file mode 100644 index 00000000..0702399a --- /dev/null +++ b/tests/testdata/other/jobSummary/violations_not_extended_view.md @@ -0,0 +1 @@ +
watch: watch1

26 Policy Violations:	20 Security	2 Operational	1 License	3 Secrets

šŸø Unlock detailed findings
\ No newline at end of file diff --git a/utils/results.go b/utils/results.go index dd51c053..b1404099 100644 --- a/utils/results.go +++ b/utils/results.go @@ -81,41 +81,12 @@ func (r *Results) IsIssuesFound() bool { // Counts the total number of unique findings in the provided results. // A unique SCA finding is identified by a unique pair of vulnerability's/violation's issueId and component id or by a result returned from one of JAS scans. -func (r *Results) CountScanResultsFindings() (total int) { - return formats.SummaryResults{Scans: r.getScanSummaryByTargets()}.GetTotalIssueCount() -} -func (r *Results) GetSummary() (summary formats.SummaryResults) { - if len(r.ScaResults) <= 1 { - summary.Scans = r.getScanSummaryByTargets() - return - } - for _, scaScan := range r.ScaResults { - summary.Scans = append(summary.Scans, r.getScanSummaryByTargets(scaScan.Target)...) - } - return -} - -// Returns a summary for the provided targets. If no targets are provided, a summary for all targets is returned. -func (r *Results) getScanSummaryByTargets(targets ...string) (summaries []formats.ScanSummaryResult) { - if len(targets) == 0 { - // No filter, one scan summary for all targets - summaries = append(summaries, getScanSummary(r.ExtendedScanResults, r.ScaResults...)) - return +func (r *Results) CountScanResultsFindings(includeVulnerabilities, includeViolations bool) (total int) { + summary := formats.ResultsSummary{Scans: GetScanSummaryByTargets(r, includeVulnerabilities, includeViolations)} + if summary.HasViolations() { + return summary.GetTotalViolations() } - for _, target := range targets { - // Get target sca results - targetScaResults := []*ScaScanResult{} - if targetScaResult := r.getScaScanResultByTarget(target); targetScaResult != nil { - targetScaResults = append(targetScaResults, targetScaResult) - } - // Get target extended results - targetExtendedResults := r.ExtendedScanResults - if targetExtendedResults != nil { - targetExtendedResults = targetExtendedResults.GetResultsForTarget(target) - } - summaries = append(summaries, getScanSummary(targetExtendedResults, targetScaResults...)) - } - return + return summary.GetTotalVulnerabilities() } type ScaScanResult struct { diff --git a/utils/results_test.go b/utils/results_test.go index 6ed05739..c85e7132 100644 --- a/utils/results_test.go +++ b/utils/results_test.go @@ -1,13 +1,8 @@ package utils import ( - "testing" - - "github.com/jfrog/jfrog-cli-security/formats" - "github.com/jfrog/jfrog-cli-security/formats/sarifutils" - "github.com/jfrog/jfrog-client-go/xray/services" - "github.com/owenrumney/go-sarif/v2/sarif" "github.com/stretchr/testify/assert" + "testing" ) func TestGetScaScanResultByTarget(t *testing.T) { @@ -49,155 +44,3 @@ func TestGetScaScanResultByTarget(t *testing.T) { }) } } - -func TestGetSummary(t *testing.T) { - dummyExtendedScanResults := &ExtendedScanResults{ - ApplicabilityScanResults: []*sarif.Run{ - sarifutils.CreateRunWithDummyResults(sarifutils.CreateDummyPassingResult("applic_CVE-2")).WithInvocations([]*sarif.Invocation{ - sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), - }), - }, - SecretsScanResults: []*sarif.Run{ - sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target1/file", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ - sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), - }), - sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target2/file", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ - sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target2")), - }), - }, - SastScanResults: []*sarif.Run{ - sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target1/file2", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ - sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), - }), - }, - } - - testCases := []struct { - name string - results Results - expected formats.SummaryResults - findingCount int - issueCount int - }{ - { - name: "Empty results", - results: Results{ScaResults: []*ScaScanResult{}}, - expected: formats.SummaryResults{Scans: []formats.ScanSummaryResult{{}}}, - findingCount: 0, - issueCount: 0, - }, - { - name: "One module result", - results: Results{ - ScaResults: []*ScaScanResult{{ - Target: "target1", - XrayResults: getDummyScaTestResults(true, false), - }}, - ExtendedScanResults: dummyExtendedScanResults, - }, - expected: formats.SummaryResults{ - Scans: []formats.ScanSummaryResult{ - { - Target: "target1", - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - ScaScanResults: &formats.ScanScaResult{ - SummaryCount: formats.TwoLevelSummaryCount{ - "Critical": formats.SummaryCount{"Undetermined": 1}, - "High": formats.SummaryCount{"Not Applicable": 1}, - }, - UniqueFindings: 2, - }, - SecretsScanResults: &formats.SummaryCount{"Low": 2}, - SastScanResults: &formats.SummaryCount{"Low": 1}, - }, - Violations: formats.TwoLevelSummaryCount{}, - }, - }, - }, - findingCount: 5, - issueCount: 5, - }, - { - name: "Multiple module results", - results: Results{ - ScaResults: []*ScaScanResult{ - { - Target: "target1", - XrayResults: getDummyScaTestResults(false, true), - }, - { - Target: "target2", - XrayResults: getDummyScaTestResults(true, true), - }, - }, - ExtendedScanResults: dummyExtendedScanResults, - }, - expected: formats.SummaryResults{ - Scans: []formats.ScanSummaryResult{ - { - Target: "target1", - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - ScaScanResults: &formats.ScanScaResult{SummaryCount: formats.TwoLevelSummaryCount{}}, - SecretsScanResults: &formats.SummaryCount{"Low": 1}, - SastScanResults: &formats.SummaryCount{"Low": 1}, - }, - Violations: formats.TwoLevelSummaryCount{ - formats.ViolationTypeSecurity.String(): formats.SummaryCount{"Critical": 1, "High": 1}, - formats.ViolationTypeLicense.String(): formats.SummaryCount{"High": 1}, - }, - }, - { - Target: "target2", - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - ScaScanResults: &formats.ScanScaResult{ - SummaryCount: formats.TwoLevelSummaryCount{"Critical": formats.SummaryCount{"": 1}}, - UniqueFindings: 1, - }, - SecretsScanResults: &formats.SummaryCount{"Low": 1}, - }, - Violations: formats.TwoLevelSummaryCount{formats.ViolationTypeSecurity.String(): formats.SummaryCount{"High": 1}}, - }, - }, - }, - findingCount: 7, - issueCount: 8, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - result := testCase.results.GetSummary() - assert.Equal(t, testCase.expected, result) - assert.Equal(t, testCase.findingCount, testCase.results.CountScanResultsFindings()) - assert.Equal(t, testCase.issueCount, testCase.results.GetSummary().GetTotalIssueCount()) - }) - } -} - -func getDummyScaTestResults(vulnerability, violation bool) (responses []services.ScanResponse) { - response := services.ScanResponse{} - switch { - case vulnerability && violation: - // Mix - response.Vulnerabilities = []services.Vulnerability{ - {IssueId: "XRAY-1", Severity: "Critical", Cves: []services.Cve{{Id: "CVE-1"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - } - response.Violations = []services.Violation{ - {ViolationType: formats.ViolationTypeSecurity.String(), WatchName: "test-watch-name", IssueId: "XRAY-2", Severity: "High", Cves: []services.Cve{{Id: "CVE-2"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - } - case vulnerability: - // only vulnerability - response.Vulnerabilities = []services.Vulnerability{ - {IssueId: "XRAY-1", Severity: "Critical", Cves: []services.Cve{{Id: "CVE-1"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - {IssueId: "XRAY-2", Severity: "High", Cves: []services.Cve{{Id: "CVE-2"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - } - case violation: - // only violation - response.Violations = []services.Violation{ - {ViolationType: formats.ViolationTypeSecurity.String(), WatchName: "test-watch-name", IssueId: "XRAY-1", Severity: "Critical", Cves: []services.Cve{{Id: "CVE-1"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - {ViolationType: formats.ViolationTypeSecurity.String(), WatchName: "test-watch-name", IssueId: "XRAY-2", Severity: "High", Cves: []services.Cve{{Id: "CVE-2"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - {ViolationType: formats.ViolationTypeLicense.String(), WatchName: "test-watch-name", IssueId: "MIT", Severity: "High", LicenseKey: "MIT", Components: map[string]services.Component{"issueId_direct_dependency": {}}}, - } - } - responses = append(responses, response) - return -} diff --git a/utils/resultstable.go b/utils/resultstable.go index 1ad6daae..bf87b948 100644 --- a/utils/resultstable.go +++ b/utils/resultstable.go @@ -90,7 +90,7 @@ func prepareViolations(violations []services.Violation, results *Results, multip return nil, nil, nil, err } switch violation.ViolationType { - case formats.ViolationTypeSecurity.String(): + case ViolationTypeSecurity.String(): cves := convertCves(violation.Cves) if results.ExtendedScanResults.EntitledForJas { for i := range cves { @@ -125,7 +125,7 @@ func prepareViolations(violations []services.Violation, results *Results, multip }, ) } - case formats.ViolationTypeLicense.String(): + case ViolationTypeLicense.String(): currSeverity, err := severityutils.ParseSeverity(violation.Severity, false) if err != nil { return nil, nil, nil, err @@ -144,7 +144,7 @@ func prepareViolations(violations []services.Violation, results *Results, multip }, ) } - case formats.ViolationTypeOperationalRisk.String(): + case ViolationTypeOperationalRisk.String(): currSeverity, err := severityutils.ParseSeverity(violation.Severity, false) if err != nil { return nil, nil, nil, err diff --git a/utils/resultwriter.go b/utils/resultwriter.go index dc68d2bb..53a21d62 100644 --- a/utils/resultwriter.go +++ b/utils/resultwriter.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/jfrog-cli-core/v2/common/format" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-security/formats" @@ -606,3 +607,200 @@ func IsEmptyScanResponse(results []services.ScanResponse) bool { func NewFailBuildError() error { return coreutils.CliError{ExitCode: coreutils.ExitCodeVulnerableBuild, ErrorMsg: "One or more of the violations found are set to fail builds that include them"} } + +func ToSummary(cmdResult *Results, includeVulnerabilities, includeViolations bool) (summary formats.ResultsSummary) { + if len(cmdResult.ScaResults) <= 1 { + summary.Scans = GetScanSummaryByTargets(cmdResult, includeVulnerabilities, includeViolations) + return + } + for _, scaScan := range cmdResult.ScaResults { + summary.Scans = append(summary.Scans, GetScanSummaryByTargets(cmdResult, includeVulnerabilities, includeViolations, scaScan.Target)...) + } + return +} + +func GetScanSummaryByTargets(r *Results, includeVulnerabilities, includeViolations bool, targets ...string) (summaries []formats.ScanSummary) { + if len(targets) == 0 { + // No filter, one scan summary for all targets + summaries = append(summaries, getScanSummary(includeVulnerabilities, includeViolations, r.ExtendedScanResults, r.ScaResults...)) + return + } + for _, target := range targets { + // Get target sca results + targetScaResults := []*ScaScanResult{} + if targetScaResult := r.getScaScanResultByTarget(target); targetScaResult != nil { + targetScaResults = append(targetScaResults, targetScaResult) + } + // Get target extended results + targetExtendedResults := r.ExtendedScanResults + if targetExtendedResults != nil { + targetExtendedResults = targetExtendedResults.GetResultsForTarget(target) + } + summaries = append(summaries, getScanSummary(includeVulnerabilities, includeViolations, targetExtendedResults, targetScaResults...)) + } + return +} + +func getScanSummary(includeVulnerabilities, includeViolations bool, extendedScanResults *ExtendedScanResults, scaResults ...*ScaScanResult) (summary formats.ScanSummary) { + if len(scaResults) == 1 { + summary.Target = scaResults[0].Target + } + if includeViolations { + summary.Violations = getScanViolationsSummary(extendedScanResults, scaResults...) + } + if includeVulnerabilities { + summary.Vulnerabilities = getScanSecurityVulnerabilitiesSummary(extendedScanResults, scaResults...) + } + return +} + +func getScanViolationsSummary(extendedScanResults *ExtendedScanResults, scaResults ...*ScaScanResult) (violations *formats.ScanViolationsSummary) { + watches := datastructures.MakeSet[string]() + parsed := datastructures.MakeSet[string]() + failBuild := false + scanIds := []string{} + moreInfoUrls := []string{} + violationsUniqueFindings := map[ViolationIssueType]formats.ResultSummary{} + // Parse unique findings + for _, scaResult := range scaResults { + for _, xrayResult := range scaResult.XrayResults { + if xrayResult.ScanId != "" { + scanIds = append(scanIds, xrayResult.ScanId) + } + if xrayResult.XrayDataUrl != "" { + moreInfoUrls = append(moreInfoUrls, xrayResult.XrayDataUrl) + } + for _, violation := range xrayResult.Violations { + watches.Add(violation.WatchName) + failBuild = failBuild || violation.FailBuild + key := violation.IssueId + violation.WatchName + if parsed.Exists(key) { + continue + } + parsed.Add(key) + severity := severityutils.GetSeverity(violation.Severity).String() + violationType := ViolationIssueType(violation.ViolationType) + if _, ok := violationsUniqueFindings[violationType]; !ok { + violationsUniqueFindings[violationType] = formats.ResultSummary{} + } + if _, ok := violationsUniqueFindings[violationType][severity]; !ok { + violationsUniqueFindings[violationType][severity] = map[string]int{} + } + if violationType == ViolationTypeSecurity { + applicableRuns := []*sarif.Run{} + if extendedScanResults != nil { + applicableRuns = append(applicableRuns, extendedScanResults.ApplicabilityScanResults...) + } + violationsUniqueFindings[violationType][severity] = mergeMaps(violationsUniqueFindings[violationType][severity], getSecuritySummaryFindings(violation.Cves, violation.IssueId, violation.Components, applicableRuns...)) + } else { + // License, Operational Risk + violationsUniqueFindings[violationType][severity][formats.NoStatus] += 1 + } + } + } + } + violations = &formats.ScanViolationsSummary{ + Watches: watches.ToSlice(), + FailBuild: failBuild, + ScanResultSummary: formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ + ScanIds: scanIds, + MoreInfoUrls: moreInfoUrls, + Security: violationsUniqueFindings[ViolationTypeSecurity], + License: violationsUniqueFindings[ViolationTypeLicense], + OperationalRisk: violationsUniqueFindings[ViolationTypeOperationalRisk], + }, + }} + return +} + +func getScanSecurityVulnerabilitiesSummary(extendedScanResults *ExtendedScanResults, scaResults ...*ScaScanResult) (vulnerabilities *formats.ScanResultSummary) { + vulnerabilities = &formats.ScanResultSummary{} + parsed := datastructures.MakeSet[string]() + for _, scaResult := range scaResults { + for _, xrayResult := range scaResult.XrayResults { + if vulnerabilities.ScaResults == nil { + vulnerabilities.ScaResults = &formats.ScaScanResultSummary{Security: formats.ResultSummary{}} + } + if xrayResult.ScanId != "" { + vulnerabilities.ScaResults.ScanIds = append(vulnerabilities.ScaResults.ScanIds, xrayResult.ScanId) + } + if xrayResult.XrayDataUrl != "" { + vulnerabilities.ScaResults.MoreInfoUrls = append(vulnerabilities.ScaResults.MoreInfoUrls, xrayResult.XrayDataUrl) + } + for _, vulnerability := range xrayResult.Vulnerabilities { + if parsed.Exists(vulnerability.IssueId) { + continue + } + parsed.Add(vulnerability.IssueId) + severity := severityutils.GetSeverity(vulnerability.Severity).String() + applicableRuns := []*sarif.Run{} + if extendedScanResults != nil { + applicableRuns = append(applicableRuns, extendedScanResults.ApplicabilityScanResults...) + } + vulnerabilities.ScaResults.Security[severity] = mergeMaps(vulnerabilities.ScaResults.Security[severity], getSecuritySummaryFindings(vulnerability.Cves, vulnerability.IssueId, vulnerability.Components, applicableRuns...)) + } + } + } + if extendedScanResults == nil { + return + } + vulnerabilities.IacResults = getJasSummaryFindings(extendedScanResults.IacScanResults...) + vulnerabilities.SecretsResults = getJasSummaryFindings(extendedScanResults.SecretsScanResults...) + vulnerabilities.SastResults = getJasSummaryFindings(extendedScanResults.SastScanResults...) + return +} + +func getSecuritySummaryFindings(cves []services.Cve, issueId string, components map[string]services.Component, applicableRuns ...*sarif.Run) map[string]int { + uniqueFindings := map[string]int{} + for _, cve := range cves { + applicableStatus := jasutils.NotScanned + if applicableInfo := getCveApplicabilityField(getCveId(cve, issueId), applicableRuns, components); applicableInfo != nil { + applicableStatus = jasutils.ConvertToApplicabilityStatus(applicableInfo.Status) + } + uniqueFindings[applicableStatus.String()] += 1 + } + if len(cves) == 0 { + // XRAY-ID, no scanners for them + uniqueFindings[jasutils.NotCovered.String()] += 1 + } + return uniqueFindings +} + +func getCveId(cve services.Cve, defaultIssueId string) string { + if cve.Id == "" { + return defaultIssueId + } + return cve.Id +} + +func mergeMaps(m1, m2 map[string]int) map[string]int { + if m1 == nil { + return m2 + } + for k, v := range m2 { + m1[k] += v + } + return m1 +} + +func getJasSummaryFindings(runs ...*sarif.Run) *formats.ResultSummary { + if len(runs) == 0 { + return nil + } + summary := formats.ResultSummary{} + for _, run := range runs { + for _, result := range run.Results { + resultLevel := sarifutils.GetResultLevel(result) + severity, err := severityutils.ParseSeverity(resultLevel, true) + if err != nil { + log.Warn(fmt.Sprintf("Failed to parse Sarif level %s. %s", resultLevel, err.Error())) + severity = severityutils.Unknown + } + if _, ok := summary[severity.String()]; !ok { + summary[severity.String()] = map[string]int{} + } + summary[severity.String()][formats.NoStatus] += len(result.Locations) + } + } + return &summary +} diff --git a/utils/resultwriter_test.go b/utils/resultwriter_test.go index 05b69bc4..0c9a5a62 100644 --- a/utils/resultwriter_test.go +++ b/utils/resultwriter_test.go @@ -3,6 +3,7 @@ package utils import ( "os" "path/filepath" + "sort" "testing" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" @@ -441,3 +442,154 @@ func TestJSONMarshall(t *testing.T) { }) } } + +func TestGetSummary(t *testing.T) { + dummyExtendedScanResults := &ExtendedScanResults{ + ApplicabilityScanResults: []*sarif.Run{ + sarifutils.CreateRunWithDummyResults(sarifutils.CreateDummyPassingResult("applic_CVE-2")).WithInvocations([]*sarif.Invocation{ + sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), + }), + }, + SecretsScanResults: []*sarif.Run{ + sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target1/file", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ + sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), + }), + sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target2/file", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ + sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target2")), + }), + }, + SastScanResults: []*sarif.Run{ + sarifutils.CreateRunWithDummyResults(sarifutils.CreateResultWithLocations("", "", "note", sarifutils.CreateLocation("target1/file2", 0, 0, 0, 0, "snippet"))).WithInvocations([]*sarif.Invocation{ + sarif.NewInvocation().WithWorkingDirectory(sarif.NewSimpleArtifactLocation("target1")), + }), + }, + } + + expectedVulnerabilities := &formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{TestMoreInfoUrl}, + Security: formats.ResultSummary{ + "Critical": map[string]int{jasutils.ApplicabilityUndetermined.String(): 1}, + "High": map[string]int{jasutils.NotApplicable.String(): 1}, + }, + }, + SecretsResults: &formats.ResultSummary{"Low": map[string]int{jasutils.NotScanned.String(): 2}}, + SastResults: &formats.ResultSummary{"Low": map[string]int{jasutils.NotScanned.String(): 1}}, + } + expectedViolations := &formats.ScanViolationsSummary{ + Watches: []string{"test-watch-name", "test-watch-name2"}, + FailBuild: true, + ScanResultSummary: formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{TestMoreInfoUrl}, + Security: formats.ResultSummary{ + "Critical": map[string]int{jasutils.ApplicabilityUndetermined.String(): 1}, + "High": map[string]int{jasutils.NotApplicable.String(): 1}, + }, + License: formats.ResultSummary{"High": map[string]int{jasutils.NotScanned.String(): 1}}, + }, + }, + } + + testCases := []struct { + name string + results *Results + includeVulnerabilities bool + includeViolations bool + expected formats.ResultsSummary + }{ + { + name: "Vulnerabilities only", + includeVulnerabilities: true, + results: &Results{ + ScaResults: []*ScaScanResult{{ + Target: "target1", + XrayResults: getDummyScaTestResults(true, true), + }}, + ExtendedScanResults: dummyExtendedScanResults, + }, + expected: formats.ResultsSummary{ + Scans: []formats.ScanSummary{{ + Target: "target1", + Vulnerabilities: expectedVulnerabilities, + }}, + }, + }, + { + name: "Violations only", + includeViolations: true, + results: &Results{ + ScaResults: []*ScaScanResult{{ + Target: "target1", + XrayResults: getDummyScaTestResults(true, true), + }}, + ExtendedScanResults: dummyExtendedScanResults, + }, + expected: formats.ResultsSummary{ + Scans: []formats.ScanSummary{{ + Target: "target1", + Violations: expectedViolations, + }}, + }, + }, + { + name: "Vulnerabilities and Violations", + includeVulnerabilities: true, + includeViolations: true, + results: &Results{ + ScaResults: []*ScaScanResult{{ + Target: "target1", + XrayResults: getDummyScaTestResults(true, true), + }}, + ExtendedScanResults: dummyExtendedScanResults, + }, + expected: formats.ResultsSummary{ + Scans: []formats.ScanSummary{{ + Target: "target1", + Violations: expectedViolations, + Vulnerabilities: expectedVulnerabilities, + }}, + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + summary := ToSummary(testCase.results, testCase.includeVulnerabilities, testCase.includeViolations) + for _, scan := range summary.Scans { + if scan.Vulnerabilities != nil { + sort.Strings(scan.Vulnerabilities.ScaResults.ScanIds) + sort.Strings(scan.Vulnerabilities.ScaResults.MoreInfoUrls) + } + if scan.Violations != nil { + sort.Strings(scan.Violations.Watches) + sort.Strings(scan.Violations.ScanResultSummary.ScaResults.ScanIds) + sort.Strings(scan.Violations.ScanResultSummary.ScaResults.MoreInfoUrls) + } + } + assert.Equal(t, testCase.expected, summary) + }) + } +} + +func getDummyScaTestResults(vulnerability, violation bool) (responses []services.ScanResponse) { + response := services.ScanResponse{} + if vulnerability { + response.Vulnerabilities = []services.Vulnerability{ + {IssueId: "XRAY-1", Severity: "Critical", Cves: []services.Cve{{Id: "CVE-1"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, + {IssueId: "XRAY-2", Severity: "High", Cves: []services.Cve{{Id: "CVE-2"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, + } + } + if violation { + response.Violations = []services.Violation{ + {ViolationType: ViolationTypeSecurity.String(), WatchName: "test-watch-name", IssueId: "XRAY-1", Severity: "Critical", Cves: []services.Cve{{Id: "CVE-1"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, + {ViolationType: ViolationTypeSecurity.String(), FailBuild: true, WatchName: "test-watch-name", IssueId: "XRAY-2", Severity: "High", Cves: []services.Cve{{Id: "CVE-2"}}, Components: map[string]services.Component{"issueId_direct_dependency": {}}}, + {ViolationType: ViolationTypeLicense.String(), WatchName: "test-watch-name2", IssueId: "MIT", Severity: "High", LicenseKey: "MIT", Components: map[string]services.Component{"issueId_direct_dependency": {}}}, + } + } + response.ScanId = TestScaScanId + response.XrayDataUrl = TestMoreInfoUrl + responses = append(responses, response) + return +} diff --git a/utils/securityJobSummary.go b/utils/securityJobSummary.go index f94724c9..a1a4be41 100644 --- a/utils/securityJobSummary.go +++ b/utils/securityJobSummary.go @@ -1,306 +1,305 @@ package utils import ( + "errors" "fmt" "path/filepath" "sort" "strings" - "github.com/jfrog/gofrog/datastructures" - "github.com/jfrog/jfrog-cli-core/v2/commandsummary" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/commandsummary" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-security/formats" - "github.com/jfrog/jfrog-cli-security/formats/sarifutils" + "github.com/jfrog/jfrog-cli-security/resources" "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/jfrog/jfrog-cli-security/utils/severityutils" - "github.com/jfrog/jfrog-client-go/utils/log" - "github.com/jfrog/jfrog-client-go/xray/services" - "github.com/owenrumney/go-sarif/v2/sarif" - "golang.org/x/exp/maps" ) const ( - Build SecuritySummarySection = "Builds" - Binary SecuritySummarySection = "Artifacts" - Modules SecuritySummarySection = "Modules" - Curation SecuritySummarySection = "Curation" + Build SecuritySummarySection = "Build-info Scans" + Binary SecuritySummarySection = "Artifact Scans" + Modules SecuritySummarySection = "Source Code Scans" + Docker SecuritySummarySection = "Docker Image Scans" + Curation SecuritySummarySection = "Curation Audit" + + PreFormat HtmlTag = "
%s
" + ImgTag HtmlTag = "\"%s\"" + CenterContent HtmlTag = "
%s
" + BoldTxt HtmlTag = "%s" + Link HtmlTag = "%s" + NewLine HtmlTag = "
%s" + DetailsWithSummary HtmlTag = "
%s%s
" + DetailsOpenWithSummary HtmlTag = "

%s

%s
" + RedColor HtmlTag = "%s" + OrangeColor HtmlTag = "%s" + GreenColor HtmlTag = "%s" + TabTag HtmlTag = " %s" + + ApplicableStatusCount SeverityStatus = "%d Applicable" + NotApplicableStatusCount SeverityStatus = "%d Not Applicable" + + maxWatchesInLine = 4 ) type SecuritySummarySection string +type HtmlTag string +type SeverityStatus string -type ScanCommandSummaryResult struct { - Section SecuritySummarySection `json:"section"` - WorkingDirectory string `json:"workingDirectory"` - Results formats.SummaryResults `json:"results"` +func (c HtmlTag) Format(args ...any) string { + return fmt.Sprintf(string(c), args...) } -type SecurityCommandsSummary struct { - BuildScanCommands []formats.SummaryResults `json:"buildScanCommands"` - ScanCommands []formats.SummaryResults `json:"scanCommands"` - AuditCommands []formats.SummaryResults `json:"auditCommands"` - CurationCommands []formats.SummaryResults `json:"curationCommands"` +func (c HtmlTag) FormatInt(value int) string { + return fmt.Sprintf(string(c), fmt.Sprintf("%d", value)) } -// Manage the job summary for security commands -func SecurityCommandsJobSummary() (js *commandsummary.CommandSummary, err error) { - return commandsummary.New(&SecurityCommandsSummary{}, "security") +func (s SeverityStatus) Format(count int) string { + return fmt.Sprintf(string(s), count) } -// Record the security command output -func RecordSecurityCommandOutput(content ScanCommandSummaryResult) (err error) { - if !commandsummary.ShouldRecordSummary() { - return +func getStatusIcon(failed bool) string { + statusIconPath := "passed.svg" + if failed { + statusIconPath = "failed.svg" } - manager, err := SecurityCommandsJobSummary() - if err != nil || manager == nil { - return - } - wd, err := coreutils.GetWorkingDirectory() - if err != nil { - return - } - content.WorkingDirectory = wd - return manager.Record(content) + return ImgTag.Format(statusIconPath, fmt.Sprintf("%s/statusIcons/%s", resources.BaseResourcesUrl, statusIconPath)) } -func (scs *SecurityCommandsSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (markdown string, err error) { - if err = loadContentFromFiles(dataFilePaths, scs); err != nil { - return "", fmt.Errorf("failed while creating security markdown: %w", err) - } - return ConvertSummaryToString(*scs) -} +type SecurityJobSummary struct{} -func loadContentFromFiles(dataFilePaths []string, scs *SecurityCommandsSummary) (err error) { - for _, dataFilePath := range dataFilePaths { - // Load file content - var cmdResults ScanCommandSummaryResult - if err = commandsummary.UnmarshalFromFilePath(dataFilePath, &cmdResults); err != nil { - return fmt.Errorf("failed while Unmarshal '%s': %w", dataFilePath, err) - } - results := cmdResults.Results - // Update the working directory - updateSummaryNamesToRelativePath(&results, cmdResults.WorkingDirectory) - // Append the new data - switch cmdResults.Section { - case Build: - scs.BuildScanCommands = append(scs.BuildScanCommands, results) - case Binary: - scs.ScanCommands = append(scs.ScanCommands, results) - case Modules: - scs.AuditCommands = append(scs.AuditCommands, results) - case Curation: - scs.CurationCommands = append(scs.CurationCommands, results) - } - } +func NewCurationSummary(cmdResult formats.ResultsSummary) (summary ScanCommandResultSummary) { + summary.ResultType = Curation + summary.Summary = cmdResult return } -func (scs *SecurityCommandsSummary) GetOrderedSectionsWithContent() (sections []SecuritySummarySection) { - if len(scs.BuildScanCommands) > 0 { - sections = append(sections, Build) - } - if len(scs.ScanCommands) > 0 { - sections = append(sections, Binary) - } - if len(scs.AuditCommands) > 0 { - sections = append(sections, Modules) - } - if len(scs.CurationCommands) > 0 { - sections = append(sections, Curation) - } +func newResultSummary(cmdResults *Results, section SecuritySummarySection, serverDetails *config.ServerDetails, vulnerabilitiesReqested, violationsReqested bool) (summary ScanCommandResultSummary) { + summary.ResultType = section + summary.Args = &ResultSummaryArgs{BaseJfrogUrl: serverDetails.Url} + summary.Summary = ToSummary(cmdResults, vulnerabilitiesReqested, violationsReqested) return - } -func (scs *SecurityCommandsSummary) getSectionSummaries(section SecuritySummarySection) (summaries []formats.SummaryResults) { - switch section { - case Build: - summaries = scs.BuildScanCommands - case Binary: - summaries = scs.ScanCommands - case Modules: - summaries = scs.AuditCommands - case Curation: - summaries = scs.CurationCommands - } +func NewBuildScanSummary(cmdResults *Results, serverDetails *config.ServerDetails, vulnerabilitiesReqested, violationsReqested bool, buildName, buildNumber string) (summary ScanCommandResultSummary) { + summary = newResultSummary(cmdResults, Build, serverDetails, vulnerabilitiesReqested, violationsReqested) + summary.Args.BuildName = buildName + summary.Args.BuildNumbers = []string{buildNumber} return } -func ConvertSummaryToString(results SecurityCommandsSummary) (summary string, err error) { - sectionsWithContent := results.GetOrderedSectionsWithContent() - addSectionTitle := len(sectionsWithContent) > 1 - var sectionSummary string - for i, section := range sectionsWithContent { - sectionSummary = getSummaryString(results.getSectionSummaries(section)...) - if addSectionTitle { - if i > 0 { - summary += "\n" - } - summary += fmt.Sprintf("#### %s\n", section) - } - summary += sectionSummary - } +func NewDockerScanSummary(cmdResults *Results, serverDetails *config.ServerDetails, vulnerabilitiesReqested, violationsReqested bool, dockerImage string) (summary ScanCommandResultSummary) { + summary = newResultSummary(cmdResults, Docker, serverDetails, vulnerabilitiesReqested, violationsReqested) + summary.Args.DockerImage = dockerImage return } -func getSummaryString(summaries ...formats.SummaryResults) (str string) { - parsed := 0 - singleScan := isSingleCommandAndScan(summaries...) - if !singleScan { - str += "| Status | Id | Details |\n|--------|----|---------|\n" +func NewBinaryScanSummary(cmdResults *Results, serverDetails *config.ServerDetails, vulnerabilitiesReqested, violationsReqested bool) (summary ScanCommandResultSummary) { + return newResultSummary(cmdResults, Binary, serverDetails, vulnerabilitiesReqested, violationsReqested) +} + +func NewAuditScanSummary(cmdResults *Results, serverDetails *config.ServerDetails, vulnerabilitiesReqested, violationsReqested bool) (summary ScanCommandResultSummary) { + return newResultSummary(cmdResults, Modules, serverDetails, vulnerabilitiesReqested, violationsReqested) +} + +type ResultSummaryArgs struct { + BaseJfrogUrl string `json:"base_jfrog_url,omitempty"` + // Args to id the result + DockerImage string `json:"docker_image,omitempty"` + BuildName string `json:"build_name,omitempty"` + BuildNumbers []string `json:"build_numbers,omitempty"` +} + +func (rsa ResultSummaryArgs) GetUrl(index commandsummary.Index, scanIds ...string) string { + if rsa.BaseJfrogUrl == "" { + rsa.BaseJfrogUrl = commandsummary.StaticMarkdownConfig.GetPlatformUrl() } - for i := range summaries { - for _, scan := range summaries[i].Scans { - if parsed > 0 { - str += "\n" - } - str += GetScanSummaryString(scan, singleScan) - parsed++ + if index == commandsummary.BuildScan { + return fmt.Sprintf("%sui/scans-list/builds-scans", rsa.BaseJfrogUrl) + } else { + baseUrl := fmt.Sprintf("%sui/onDemandScanning", rsa.BaseJfrogUrl) + if len(scanIds) > 0 && scanIds[0] != "" { + return fmt.Sprintf("%s/%s", baseUrl, scanIds[0]) } + return fmt.Sprintf("%s/list", baseUrl) + } +} + +func (rsa ResultSummaryArgs) ToArgs(index commandsummary.Index) (args []string) { + if index == commandsummary.BuildScan { + args = append(args, rsa.BuildName) + args = append(args, rsa.BuildNumbers...) + } else if index == commandsummary.DockerScan { + args = append(args, rsa.DockerImage) } return } -func isSingleCommandAndScan(summaries ...formats.SummaryResults) bool { - if len(summaries) != 1 { - return false +type ScanCommandResultSummary struct { + ResultType SecuritySummarySection `json:"resultType"` + Args *ResultSummaryArgs `json:"args,omitempty"` + Summary formats.ResultsSummary `json:"summary"` +} + +// Manage the job summary for security commands +func NewSecurityJobSummary() (js *commandsummary.CommandSummary, err error) { + return commandsummary.New(&SecurityJobSummary{}, "security") +} + +// Record the security command outputs +func RecordSecurityCommandSummary(content ScanCommandResultSummary) (err error) { + if !commandsummary.ShouldRecordSummary() { + return + } + manager, err := NewSecurityJobSummary() + if err != nil || manager == nil { + return + } + wd, err := coreutils.GetWorkingDirectory() + if err != nil { + return } - if len(summaries[0].Scans) != 1 { - return false + updateSummaryNamesToRelativePath(&content.Summary, wd) + if index := getDataIndexFromSection(content.ResultType); index != "" { + return recordIndexData(manager, content, index) } - // One command and one scan - return true + return manager.Record(content) } -func GetScanSummaryString(summary formats.ScanSummaryResult, singleData bool) (content string) { - // single data -> no table - hasIssues := summary.HasIssues() - if !hasIssues { - if singleData { - return "```\nāœ… No issues were found\n```" +func updateSummaryNamesToRelativePath(summary *formats.ResultsSummary, wd string) { + for i, scan := range summary.Scans { + if scan.Target == "" { + continue } - return fmt.Sprintf("| āœ… | %s | |", summary.Target) - } - issueDetails := getDetailsString(summary) - if singleData { - return fmt.Sprintf("
%s
", issueDetails) + if !strings.HasPrefix(scan.Target, wd) { + continue + } + if scan.Target == wd { + summary.Scans[i].Target = filepath.Base(wd) + } + summary.Scans[i].Target = strings.TrimPrefix(scan.Target, wd) } - return fmt.Sprintf("| āŒ | %s |
%s
|", summary.Target, issueDetails) } -func getDetailsString(summary formats.ScanSummaryResult) string { - // If summary includes curation issues, then it means only curation issues are in this summary, no need to continue - if summary.HasBlockedCuration() { - return getBlockedCurationSummaryString(summary) - } - violationContent := getViolationSummaryString(summary) - vulnerabilitiesContent := getVulnerabilitiesSummaryString(summary) - delimiter := "" - if len(violationContent) > 0 && len(vulnerabilitiesContent) > 0 { - delimiter = "
" +func getDataIndexFromSection(section SecuritySummarySection) commandsummary.Index { + switch section { + case Build: + return commandsummary.BuildScan + case Binary: + return commandsummary.BinariesScan + case Modules: + return commandsummary.BinariesScan + case Docker: + return commandsummary.DockerScan } - return violationContent + delimiter + vulnerabilitiesContent + // No index for the section + return "" } -func getBlockedCurationSummaryString(summary formats.ScanSummaryResult) (content string) { - if !summary.HasBlockedCuration() { - return - } - content += fmt.Sprintf("Total Number of Packages: %d", summary.CuratedPackages.GetTotalPackages()) - content += fmt.Sprintf("
šŸŸ¢ Total Number of Approved Packages: %d", summary.CuratedPackages.Approved) - content += fmt.Sprintf("
šŸ”“ Total Number of Blocked Packages: %d", summary.CuratedPackages.Blocked.GetCountOfKeys(false)) - if summary.CuratedPackages.Blocked.GetTotal() > 0 { - var blocked []struct { - BlockedName string - BlockedValue formats.SummaryCount +func recordIndexData(manager *commandsummary.CommandSummary, content ScanCommandResultSummary, index commandsummary.Index) (err error) { + if index == commandsummary.BinariesScan { + for _, scan := range content.Summary.Scans { + err = errors.Join(err, manager.RecordWithIndex(newScanCommandResultSummary(content.ResultType, content.Args, scan), index, scan.Target)) } - // Sort the blocked packages by name - for blockTypeName, blockTypeValue := range summary.CuratedPackages.Blocked { - blocked = append(blocked, struct { - BlockedName string - BlockedValue formats.SummaryCount - }{BlockedName: blockTypeName, BlockedValue: blockTypeValue}) + } else { + // Save the results based on the index and the provided arguments (keys) + // * Docker scan results are saved with the image tag as the key + // * Build scan results are saved with the build name and number as the key + err = manager.RecordWithIndex(content, index, content.Args.ToArgs(index)...) + } + return +} + +func newScanCommandResultSummary(resultType SecuritySummarySection, args *ResultSummaryArgs, scans ...formats.ScanSummary) ScanCommandResultSummary { + return ScanCommandResultSummary{ResultType: resultType, Args: args, Summary: formats.ResultsSummary{Scans: scans}} +} + +func loadContent(dataFiles []string, filterSections ...SecuritySummarySection) ([]formats.ResultsSummary, ResultSummaryArgs, error) { + data := []formats.ResultsSummary{} + args := ResultSummaryArgs{} + for _, dataFilePath := range dataFiles { + // Load file content + var cmdResults ScanCommandResultSummary + if err := commandsummary.UnmarshalFromFilePath(dataFilePath, &cmdResults); err != nil { + return nil, args, fmt.Errorf("failed while Unmarshal '%s': %w", dataFilePath, err) } - sort.Slice(blocked, func(i, j int) bool { - return blocked[i].BlockedName > blocked[j].BlockedName - }) - // Display the blocked packages - for index, blockStruct := range blocked { - subScanPrefix := fmt.Sprintf("
%s", getListItemPrefix(index, len(blocked))) - subScanPrefix += blockStruct.BlockedName - content += fmt.Sprintf("%s (%d)", subScanPrefix, blockStruct.BlockedValue.GetTotal()) + if len(filterSections) == 0 || (slices.Contains(filterSections, cmdResults.ResultType)) { + data = append(data, cmdResults.Summary) + if cmdResults.Args == nil { + continue + } + if args.BaseJfrogUrl == "" { + args.BaseJfrogUrl = cmdResults.Args.BaseJfrogUrl + } + if args.DockerImage == "" { + args.DockerImage = cmdResults.Args.DockerImage + } + if args.BuildName == "" { + args.BuildName = cmdResults.Args.BuildName + } + args.BuildNumbers = append(args.BuildNumbers, cmdResults.Args.BuildNumbers...) } } + return data, args, nil +} + +func (js *SecurityJobSummary) BinaryScan(filePaths []string) (generator DynamicMarkdownGenerator, err error) { + generator = DynamicMarkdownGenerator{index: commandsummary.BinariesScan, dataFiles: filePaths, extendedView: commandsummary.StaticMarkdownConfig.IsExtendedSummary()} + err = generator.loadContentFromFiles() return } -func getViolationSummaryString(summary formats.ScanSummaryResult) (content string) { - if !summary.HasViolations() { - return - } - content += fmt.Sprintf("Violations: %d -", summary.GetTotalViolationCount()) - content += GetSummaryContentString(summary.Violations.GetCombinedLowerLevel(), ", ", true) +func (js *SecurityJobSummary) BuildScan(filePaths []string) (generator DynamicMarkdownGenerator, err error) { + generator = DynamicMarkdownGenerator{index: commandsummary.BuildScan, dataFiles: filePaths, extendedView: commandsummary.StaticMarkdownConfig.IsExtendedSummary()} + err = generator.loadContentFromFiles() return } -func getVulnerabilitiesSummaryString(summary formats.ScanSummaryResult) (content string) { - if !summary.HasSecurityVulnerabilities() { - return - } - content += fmt.Sprintf("Security Vulnerabilities: %d (%d unique)", summary.Vulnerabilities.GetTotalIssueCount(), summary.Vulnerabilities.GetTotalUniqueIssueCount()) - // Display sub scans with issues - subScansWithIssues := summary.Vulnerabilities.GetSubScansWithIssues() - for i, subScanType := range subScansWithIssues { - content += fmt.Sprintf("
%s", getListItemPrefix(i, len(subScansWithIssues))) - subScanPrefix := fmt.Sprintf("%d ", summary.Vulnerabilities.GetSubScanTotalIssueCount(subScanType)) - switch subScanType { - case formats.ScaScan: - subScanPrefix += "SCA " - case formats.IacScan: - subScanPrefix += "IAC " - case formats.SecretsScan: - subScanPrefix += "Secrets " - case formats.SastScan: - subScanPrefix += "SAST " - } - content += subScanPrefix + getSubScanSummaryCountsString(summary, subScanType, getPrefixPadding(subScanPrefix)) - } +func (js *SecurityJobSummary) DockerScan(filePaths []string) (generator DynamicMarkdownGenerator, err error) { + generator = DynamicMarkdownGenerator{index: commandsummary.DockerScan, dataFiles: filePaths, extendedView: commandsummary.StaticMarkdownConfig.IsExtendedSummary()} + err = generator.loadContentFromFiles() return } -func getPrefixPadding(prefix string) int { - // 4 spaces for the list item prefix (len of symbol not equal to actual length) - return 4 + len(prefix) +func (js *SecurityJobSummary) GetNonScannedResult() (generator EmptyMarkdownGenerator) { + return EmptyMarkdownGenerator{} } -func getListItemPrefix(index, total int) (content string) { - if index == total-1 { - content += "ā””ā”€ā”€ " +// Generate the Security section (Curation) +func (js *SecurityJobSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (markdown string, err error) { + curationData, _, err := loadContent(dataFilePaths, Curation) + if err != nil { return } - content += "ā”œā”€ā”€ " - return + return GenerateSecuritySectionMarkdown(curationData) } -func getSubScanSummaryCountsString(summary formats.ScanSummaryResult, subScanType formats.SummarySubScanType, padding int) (content string) { - switch subScanType { - case formats.ScaScan: - content += GetScaSummaryCountString(*summary.Vulnerabilities.ScaScanResults, padding) - case formats.IacScan: - content += GetSeveritySummaryCountString(*summary.Vulnerabilities.IacScanResults, padding) - case formats.SecretsScan: - content += GetSeveritySummaryCountString(*summary.Vulnerabilities.SecretsScanResults, padding) - case formats.SastScan: - content += GetSeveritySummaryCountString(*summary.Vulnerabilities.SastScanResults, padding) +func GenerateSecuritySectionMarkdown(curationData []formats.ResultsSummary) (markdown string, err error) { + if !hasCurationCommand(curationData) { + return + } + // Create the markdown content + markdown += fmt.Sprintf("\n\n#### %s\n| Audit Summary | Project name | Audit Details |\n|--------|--------|---------|", Curation) + for i := range curationData { + for _, summary := range curationData[i].Scans { + status := getStatusIcon(false) + if summary.HasBlockedPackages() { + status = getStatusIcon(true) + } + markdown += fmt.Sprintf("\n| %s | %s | %s |", status, summary.Target, PreFormat.Format(getCurationDetailsString(summary))) + } } + markdown = DetailsOpenWithSummary.Format("šŸ”’ Security Summary", markdown) return } -func hasApplicableDataToDisplayInSummary(summary formats.TwoLevelSummaryCount) bool { - for _, statuses := range summary { - sorted := getSummarySortedKeysToDisplay(maps.Keys(statuses)...) - for _, status := range sorted { - if _, ok := statuses[status]; ok && statuses[status] > 0 { +func hasCurationCommand(data []formats.ResultsSummary) bool { + for _, summary := range data { + for _, scan := range summary.Scans { + if scan.HasCuratedPackages() { return true } } @@ -308,246 +307,285 @@ func hasApplicableDataToDisplayInSummary(summary formats.TwoLevelSummaryCount) b return false } -func GetScaSummaryCountString(summary formats.ScanScaResult, padding int) (content string) { - if summary.SummaryCount.GetTotal() == 0 { +type blockedPackageByType struct { + BlockedType string + BlockedSummary map[string]int +} + +func getCurationDetailsString(summary formats.ScanSummary) (content string) { + if summary.CuratedPackages == nil { return } - if !hasApplicableDataToDisplayInSummary(summary.SummaryCount) { - return GetSeveritySummaryCountString(summary.SummaryCount.GetCombinedLowerLevel(), padding) + content += fmt.Sprintf("Total Number of resolved packages: %s", BoldTxt.FormatInt(summary.CuratedPackages.PackageCount)) + blockedPackages := summary.CuratedPackages.GetBlockedCount() + if blockedPackages == 0 { + return } - // Display contextual-analysis details - keys := getSummarySortedKeysToDisplay(maps.Keys(summary.SummaryCount)...) - for i, severity := range keys { - if i > 0 { - content += "
" + strings.Repeat(" ", padding) - } - statusCounts := summary.SummaryCount[severity] - content += fmt.Sprintf("%s%s", - fmt.Sprintf(summaryContentToFormatString[severity], statusCounts.GetTotal()), - GetSummaryContentString(statusCounts, ", ", true), + content += NewLine.Format(fmt.Sprintf("šŸŸ¢ Approved packages: %s", BoldTxt.FormatInt(summary.CuratedPackages.GetApprovedCount()))) + content += NewLine.Format(fmt.Sprintf("šŸ”“ Blocked packages: %s", BoldTxt.FormatInt(blockedPackages))) + // Display the blocked packages grouped by type + var blocked []blockedPackageByType + // Sort the blocked packages by name + for _, blockTypeValue := range summary.CuratedPackages.Blocked { + blocked = append(blocked, toBlockedPackgeByType(blockTypeValue)) + } + sort.Slice(blocked, func(i, j int) bool { + return blocked[i].BlockedType > blocked[j].BlockedType + }) + // Display the blocked packages + for _, blockStruct := range blocked { + content += DetailsWithSummary.Format( + fmt.Sprintf("%s (%s)", blockStruct.BlockedType, BoldTxt.FormatInt(len(blockStruct.BlockedSummary))), + getBlockedPackages(blockStruct.BlockedSummary), ) } return } -var ( - // Convert summary of the given keys to the needed string - summaryContentToFormatString = map[string]string{ - "Critical": `ā—ļø %d Critical`, - "High": `šŸ”“ %d High`, - "Medium": `šŸŸ  %d Medium`, - "Low": `šŸŸ” %d Low`, - "Unknown": `āšŖļø %d Unknown`, - jasutils.Applicable.String(): "%d " + jasutils.Applicable.String(), - jasutils.NotApplicable.String(): "%d " + jasutils.NotApplicable.String(), - formats.ViolationTypeSecurity.String(): "%d Security", - formats.ViolationTypeLicense.String(): "%d License", - formats.ViolationTypeOperationalRisk.String(): "%d Operational", - } - // AllowedSorted is the order of the keys to display in the summary - allowedSorted = []string{ - "Critical", "High", "Medium", "Low", "Unknown", - jasutils.Applicable.String(), jasutils.NotApplicable.String(), - formats.ViolationTypeSecurity.String(), formats.ViolationTypeLicense.String(), formats.ViolationTypeOperationalRisk.String(), - } -) +func toBlockedPackgeByType(blockTypeValue formats.BlockedPackages) blockedPackageByType { + return blockedPackageByType{BlockedType: formatPolicyAndCond(blockTypeValue.Policy, blockTypeValue.Condition), BlockedSummary: blockTypeValue.Packages} +} -func getSummarySortedKeysToDisplay(keys ...string) (sorted []string) { - if len(keys) == 0 { - return - } - keysSet := datastructures.MakeSetFromElements(keys...) - for _, key := range allowedSorted { - if keysSet.Exists(key) { - sorted = append(sorted, key) +func formatPolicyAndCond(policy, cond string) string { + return fmt.Sprintf("%s %s, %s %s", BoldTxt.Format("Violated Policy:"), policy, BoldTxt.Format("Condition:"), cond) +} + +func getBlockedPackages(blockedSummary map[string]int) (content string) { + blocked := maps.Keys(blockedSummary) + sort.Strings(blocked) + for i, blockedPackage := range blocked { + blockedPackageStr := fmt.Sprintf("šŸ“¦ %s", blockedPackage) + if i > 0 { + blockedPackageStr = NewLine.Format(blockedPackageStr) } + content += blockedPackageStr } return } -func GetSeveritySummaryCountString(summary formats.SummaryCount, padding int) (content string) { - return GetSummaryContentString(summary, "
"+strings.Repeat(" ", padding), false) +type EmptyMarkdownGenerator struct{} + +func (g EmptyMarkdownGenerator) GetViolations() (content string) { + return PreFormat.Format("ā„¹ļø Not Scanned") +} + +func (g EmptyMarkdownGenerator) GetVulnerabilities() (content string) { + return PreFormat.Format("ā„¹ļø Not Scanned") } -func GetSummaryContentString(summary formats.SummaryCount, delimiter string, wrapWithBracket bool) (content string) { - // sort and filter - keys := getSummarySortedKeysToDisplay(maps.Keys(summary)...) - if len(keys) == 0 { +type DynamicMarkdownGenerator struct { + index commandsummary.Index + extendedView bool + dataFiles []string + content []formats.ResultsSummary + args ResultSummaryArgs +} + +func (mg *DynamicMarkdownGenerator) loadContentFromFiles() (err error) { + if len(mg.content) > 0 { + // Already loaded return } - for i, key := range keys { - if i > 0 { - content += delimiter - } - content += fmt.Sprintf(summaryContentToFormatString[key], summary[key]) + mg.content, mg.args, err = loadContent(mg.dataFiles) + return +} + +func (mg DynamicMarkdownGenerator) GetViolations() (content string) { + summary := formats.GetViolationSummaries(mg.content...) + if summary == nil { + content = PreFormat.Format("No watch is defined") + return } - if wrapWithBracket { - content = fmt.Sprintf(" (%s)", content) + resultsMarkdown := mg.generateResultsMarkdown(true, getJfrogUrl(mg.index, mg.args, &summary.ScanResultSummary, mg.extendedView), &summary.ScanResultSummary) + if len(summary.Watches) == 0 { + content = resultsMarkdown + return } + content = getWatchesMarkdown(summary.Watches) + NewLine.Format(resultsMarkdown) return } -func updateSummaryNamesToRelativePath(summary *formats.SummaryResults, wd string) { - for i, scan := range summary.Scans { - if scan.Target == "" { - continue +// If more than maxWatchesInLine watches, put maxWatchesInLine at each line +func getWatchesMarkdown(watches []string) (content string) { + sort.Strings(watches) + watchesStr := "" + multiLine := len(watches) > maxWatchesInLine + for i := 0; i < len(watches); i += maxWatchesInLine { + j := i + maxWatchesInLine + if j > len(watches) { + j = len(watches) } - if !strings.HasPrefix(scan.Target, wd) { - continue - } - if scan.Target == wd { - summary.Scans[i].Target = filepath.Base(wd) + watchLine := strings.Join(watches[i:j], ", ") + if multiLine { + watchLine = NewLine.Format(watchLine) } - summary.Scans[i].Target = strings.TrimPrefix(scan.Target, wd) + watchesStr += watchLine + } + prefix := "watch" + if len(watches) > 1 { + prefix += "es" } + content = PreFormat.Format(prefix + ": " + watchesStr) + return } -func getScanSummary(extendedScanResults *ExtendedScanResults, scaResults ...*ScaScanResult) (summary formats.ScanSummaryResult) { - if len(scaResults) == 0 { +func (mg DynamicMarkdownGenerator) GetVulnerabilities() (content string) { + summary := formats.GetVulnerabilitiesSummaries(mg.content...) + if summary == nil { + // We are in violation mode and vulnerabilities are not requested (no info to show) return } - if len(scaResults) == 1 { - summary.Target = scaResults[0].Target - } - // Parse violations - summary.Violations = getScanViolationsSummary(scaResults...) - // Parse vulnerabilities - summary.Vulnerabilities = getScanSecurityVulnerabilitiesSummary(extendedScanResults, scaResults...) + content = mg.generateResultsMarkdown(false, getJfrogUrl(mg.index, mg.args, summary, mg.extendedView), summary) return } -func getScanViolationsSummary(scaResults ...*ScaScanResult) (violations formats.TwoLevelSummaryCount) { - vioUniqueFindings := map[string]IssueDetails{} - if len(scaResults) == 0 { - return +func getJfrogUrl(index commandsummary.Index, args ResultSummaryArgs, summary *formats.ScanResultSummary, extendedView bool) (url string) { + if !extendedView { + return Link.Format(commandsummary.StaticMarkdownConfig.GetExtendedSummaryLangPage(), "šŸø Unlock detailed findings") } - // Parse unique findings - for _, scaResult := range scaResults { - for _, xrayResult := range scaResult.XrayResults { - for _, violation := range xrayResult.Violations { - details := IssueDetails{FirstLevelValue: violation.ViolationType, SecondLevelValue: severityutils.GetSeverity(violation.Severity).String()} - for compId := range violation.Components { - if violation.ViolationType == formats.ViolationTypeSecurity.String() { - for _, cve := range violation.Cves { - vioUniqueFindings[getCveId(cve, violation.IssueId)+compId] = details - } - } else { - vioUniqueFindings[violation.IssueId+compId] = details - } - } - } - } + if moreInfoUrls := summary.GetMoreInfoUrls(); len(moreInfoUrls) > 0 { + return Link.Format(moreInfoUrls[0], "See the results of the scan in JFrog") + } + if defaultUrl := args.GetUrl(index, summary.GetScanIds()...); defaultUrl != "" { + return Link.Format(defaultUrl, "See the results of the scan in JFrog") } - // Aggregate - return issueDetailsToSummaryCount(vioUniqueFindings) + return } -func getScanSecurityVulnerabilitiesSummary(extendedScanResults *ExtendedScanResults, scaResults ...*ScaScanResult) (summary *formats.ScanVulnerabilitiesSummary) { - summary = &formats.ScanVulnerabilitiesSummary{} - if extendedScanResults == nil { - summary.ScaScanResults = getScaSummaryResults(scaResults) - return - } - if len(scaResults) > 0 { - summary.ScaScanResults = getScaSummaryResults(scaResults, extendedScanResults.ApplicabilityScanResults...) +func (mg DynamicMarkdownGenerator) generateResultsMarkdown(violations bool, moreInfoUrl string, content *formats.ScanResultSummary) (markdown string) { + if !content.HasIssues() { + markdown = getNoIssuesMarkdown(violations) + } else { + markdown = getResultsTypesSummaryString(mg.index, violations, content) + details := "" + if mg.extendedView { + details = getResultsSeveritySummaryString(content) + } + markdown += NewLine.Format(details) + if moreInfoUrl != "" { + markdown += NewLine.Format(moreInfoUrl) + } } - summary.IacScanResults = getJASSummaryCount(extendedScanResults.IacScanResults...) - summary.SecretsScanResults = getJASSummaryCount(extendedScanResults.SecretsScanResults...) - summary.SastScanResults = getJASSummaryCount(extendedScanResults.SastScanResults...) + markdown = PreFormat.Format(markdown) return } -type IssueDetails struct { - FirstLevelValue string - SecondLevelValue string +func getNoIssuesMarkdown(violations bool) (markdown string) { + noIssuesStr := "No security issues found" + if violations { + noIssuesStr = "No violations found" + } + return noIssuesStr } -func getCveId(cve services.Cve, defaultIssueId string) string { - if cve.Id == "" { - return defaultIssueId - } - return cve.Id +func getCenteredSvgWithText(svg, text string) (markdown string) { + return CenterContent.Format(fmt.Sprintf("%s %s", svg, text)) } -func getSecurityIssueFindings(cves []services.Cve, issueId string, severity severityutils.Severity, components map[string]services.Component, applicableRuns ...*sarif.Run) (findings, uniqueFindings map[string]IssueDetails) { - findings = map[string]IssueDetails{} - uniqueFindings = map[string]IssueDetails{} - for _, cve := range cves { - cveId := getCveId(cve, issueId) - applicableStatus := jasutils.NotScanned - if applicableInfo := getCveApplicabilityField(cveId, applicableRuns, components); applicableInfo != nil { - applicableStatus = jasutils.ConvertToApplicabilityStatus(applicableInfo.Status) +func getResultsTypesSummaryString(index commandsummary.Index, violations bool, summary *formats.ScanResultSummary) (content string) { + if violations { + content = fmt.Sprintf("%d Policy Violations:", summary.GetTotal()) + } else { + if index == commandsummary.DockerScan || index == commandsummary.BinariesScan { + content = fmt.Sprintf("%d Security issues are grouped by CVE number:", summary.GetTotal()) + } else { + content = fmt.Sprintf("%d Security Issues:", summary.GetTotal()) + } + } + if summary.ScaResults != nil { + if violations { + if count := summary.GetTotal(formats.ScaSecurityResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.ScaSecurityResult.String())) + } + if count := summary.GetTotal(formats.ScaOperationalResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.ScaOperationalResult.String())) + } + if count := summary.GetTotal(formats.ScaLicenseResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.ScaLicenseResult.String())) + } + } else { + if count := summary.GetTotal(formats.ScaSecurityResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.ScaResult.String())) + } } - uniqueFindings[cveId] = IssueDetails{ - FirstLevelValue: severity.String(), - SecondLevelValue: applicableStatus.String(), + } + if summary.SecretsResults != nil { + if count := summary.GetTotal(formats.SecretsResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.SecretsResult.String())) + } + } + if summary.SastResults != nil { + if count := summary.GetTotal(formats.SastResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.SastResult.String())) } - for compId := range components { - findings[cveId+compId] = uniqueFindings[cveId] + } + if summary.IacResults != nil { + if count := summary.GetTotal(formats.IacResult); count > 0 { + content += TabTag.Format(fmt.Sprintf("%d %s", count, formats.IacResult.String())) } } return } -func getScaSummaryResults(scaScanResults []*ScaScanResult, applicableRuns ...*sarif.Run) *formats.ScanScaResult { - vulFindings := map[string]IssueDetails{} - vulUniqueFindings := map[string]IssueDetails{} - if len(scaScanResults) == 0 { - return nil - } - // Aggregate unique findings - for _, scaResult := range scaScanResults { - for _, xrayResult := range scaResult.XrayResults { - for _, vulnerability := range xrayResult.Vulnerabilities { - vulFinding, vulUniqueFinding := getSecurityIssueFindings(vulnerability.Cves, vulnerability.IssueId, severityutils.GetSeverity(vulnerability.Severity), vulnerability.Components, applicableRuns...) - for key, value := range vulFinding { - vulFindings[key] = value - } - for key, value := range vulUniqueFinding { - vulUniqueFindings[key] = value - } - } - } +func getResultsSeveritySummaryString(summary *formats.ScanResultSummary) (markdown string) { + details := summary.GetSummaryBySeverity() + if details.GetTotal(severityutils.Critical.String()) > 0 { + markdown += NewLine.Format(getSeverityMarkdown(severityutils.Critical, details)) } - return &formats.ScanScaResult{ - SummaryCount: issueDetailsToSummaryCount(vulFindings), - UniqueFindings: issueDetailsToSummaryCount(vulUniqueFindings).GetTotal(), + if details.GetTotal(severityutils.High.String()) > 0 { + markdown += NewLine.Format(getSeverityMarkdown(severityutils.High, details)) } + if details.GetTotal(severityutils.Medium.String()) > 0 { + markdown += NewLine.Format(getSeverityMarkdown(severityutils.Medium, details)) + } + if details.GetTotal(severityutils.Low.String()) > 0 { + markdown += NewLine.Format(getSeverityMarkdown(severityutils.Low, details)) + } + if details.GetTotal(severityutils.Unknown.String()) > 0 { + markdown += NewLine.Format(getSeverityMarkdown(severityutils.Unknown, details)) + } + return } -func issueDetailsToSummaryCount(uniqueFindings map[string]IssueDetails) formats.TwoLevelSummaryCount { - summary := formats.TwoLevelSummaryCount{} - for _, details := range uniqueFindings { - if _, ok := summary[details.FirstLevelValue]; !ok { - summary[details.FirstLevelValue] = formats.SummaryCount{} - } - summary[details.FirstLevelValue][details.SecondLevelValue]++ - } - return summary -} - -func getJASSummaryCount(runs ...*sarif.Run) *formats.SummaryCount { - if len(runs) == 0 { - return nil - } - count := formats.SummaryCount{} - issueToSeverity := map[string]string{} - for _, run := range runs { - for _, result := range run.Results { - for _, location := range result.Locations { - resultLevel := sarifutils.GetResultLevel(result) - severity, err := severityutils.ParseSeverity(resultLevel, true) - if err != nil { - log.Warn(fmt.Sprintf("Failed to parse Sarif level %s. %s", resultLevel, err.Error())) - severity = severityutils.Unknown - } - severityutils.GetSeverity(sarifutils.GetResultLevel(result)) - issueToSeverity[sarifutils.GetLocationId(location)] = severity.String() - } +func getSeverityMarkdown(severity severityutils.Severity, details formats.ResultSummary) (markdown string) { + svg := getSeverityIcon(severity) + severityStr := severity.String() + totalSeverityIssues := details.GetTotal(severityStr) + severityMarkdown := fmt.Sprintf("%d %s%s", totalSeverityIssues, severityStr, getSeverityStatusesCountString(details[severityStr])) + return getCenteredSvgWithText(svg, severityMarkdown) +} + +func getSeverityIcon(severity severityutils.Severity) string { + return severityutils.GetSeverityIcon(severity) +} + +func getSeverityStatusesCountString(statusCounts map[string]int) string { + return generateSeverityStatusesCountString(getSeverityDisplayStatuses(statusCounts)) +} + +func getSeverityDisplayStatuses(statusCounts map[string]int) (displayData map[SeverityStatus]int) { + displayData = map[SeverityStatus]int{} + for status, count := range statusCounts { + switch status { + case jasutils.Applicability.String(): + displayData[ApplicableStatusCount] += count + case jasutils.NotApplicable.String(): + displayData[NotApplicableStatusCount] += count } } - for _, severity := range issueToSeverity { - count[severity]++ + return displayData +} + +func generateSeverityStatusesCountString(displayData map[SeverityStatus]int) string { + if len(displayData) == 0 { + return "" + } + display := []string{} + if count, ok := displayData[ApplicableStatusCount]; ok { + display = append(display, ApplicableStatusCount.Format(count)) + } + if count, ok := displayData[NotApplicableStatusCount]; ok { + display = append(display, NotApplicableStatusCount.Format(count)) } - return &count + return fmt.Sprintf(" (%s)", strings.Join(display, ", ")) } diff --git a/utils/securityJobSummary_test.go b/utils/securityJobSummary_test.go index ed25a1c9..e447a7fb 100644 --- a/utils/securityJobSummary_test.go +++ b/utils/securityJobSummary_test.go @@ -1,189 +1,438 @@ package utils import ( + "fmt" "os" "path/filepath" "strings" "testing" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/commandsummary" + coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli-security/formats" + "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/stretchr/testify/assert" ) var ( summaryExpectedContentDir = filepath.Join("..", "tests", "testdata", "other", "jobSummary") -) + testPlatformUrl = "https://test-platform-url.jfrog.io/" + testMoreInfoUrl = "https://test-more-info-url.jfrog.io/" -func TestConvertSummaryToString(t *testing.T) { - wd, err := os.Getwd() - assert.NoError(t, err) + securityScaResults = formats.ResultSummary{ + "Critical": map[string]int{jasutils.Applicable.String(): 2, jasutils.NotApplicable.String(): 2, jasutils.NotCovered.String(): 3, jasutils.ApplicabilityUndetermined.String(): 1}, + "High": map[string]int{jasutils.Applicable.String(): 2, jasutils.ApplicabilityUndetermined.String(): 3}, + "Low": map[string]int{jasutils.NotApplicable.String(): 3}, + "Unknown": map[string]int{jasutils.NotCovered.String(): 1}, + } + violationResults = formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{testMoreInfoUrl}, + Security: securityScaResults, + License: formats.ResultSummary{"High": map[string]int{formats.NoStatus: 1}}, + OperationalRisk: formats.ResultSummary{"Low": map[string]int{formats.NoStatus: 2}}, + }, + SecretsResults: &formats.ResultSummary{"Medium": map[string]int{formats.NoStatus: 3}}, + } +) - testCases := []struct { - name string - summary SecurityCommandsSummary - expectedContentPath string - }{ - { - name: "One Section - No Issues", - summary: getDummySecurityCommandsSummary( - ScanCommandSummaryResult{ - Section: Binary, - WorkingDirectory: wd, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{{Target: filepath.Join(wd, "binary-name")}}}, - }, - ), - expectedContentPath: filepath.Join(summaryExpectedContentDir, "single_no_issue.md"), +func TestSaveLoadData(t *testing.T) { + testDockerScanSummary := ScanCommandResultSummary{ + ResultType: Docker, + Args: &ResultSummaryArgs{ + BaseJfrogUrl: testPlatformUrl, + DockerImage: "dockerImage:version", }, - { - name: "One Section - With Issues", - summary: getDummySecurityCommandsSummary( - ScanCommandSummaryResult{ - Section: Build, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{{ - Target: "build-name (build-number)", - Violations: formats.TwoLevelSummaryCount{formats.ViolationTypeLicense.String(): formats.SummaryCount{"High": 1}}, - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{SecretsScanResults: &formats.SummaryCount{"Low": 1, "High": 2}}, - }}}, + Summary: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ + { + Target: filepath.Join("path", "to", "image.tar"), + Vulnerabilities: &formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{testMoreInfoUrl}, + Security: securityScaResults, + }, + }, + Violations: &formats.ScanViolationsSummary{ + Watches: []string{"watch1"}, + ScanResultSummary: violationResults, + }, }, - ), - expectedContentPath: filepath.Join(summaryExpectedContentDir, "single_issue.md"), + }, }, - { - name: "Multiple Sections", - summary: getDummySecurityCommandsSummary( - ScanCommandSummaryResult{ - Section: Build, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{{Target: "build-name (build-number)"}}}, + } + testBinaryScanSummary := ScanCommandResultSummary{ + ResultType: Binary, + Args: &ResultSummaryArgs{ + BaseJfrogUrl: testPlatformUrl, + }, + Summary: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ + { + Target: filepath.Join("path", "to", "binary"), + Vulnerabilities: &formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{"scan-id-1"}, + Security: formats.ResultSummary{"Critical": map[string]int{formats.NoStatus: 33}, "Low": map[string]int{formats.NoStatus: 11}}, + }, + }, }, - ScanCommandSummaryResult{ - Section: Build, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{{ - Target: "build-name (build-number)", - Violations: formats.TwoLevelSummaryCount{ - formats.ViolationTypeSecurity.String(): formats.SummaryCount{"High": 1, "Medium": 1}, - formats.ViolationTypeLicense.String(): formats.SummaryCount{"Medium": 1}, - formats.ViolationTypeOperationalRisk.String(): formats.SummaryCount{"Low": 1}, + { + Target: filepath.Join("path", "to", "binary2"), + Vulnerabilities: &formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{"scan-id-2"}, }, - }}}, + }, }, - ScanCommandSummaryResult{ - Section: Binary, - WorkingDirectory: wd, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{ - { - Target: filepath.Join(wd, "binary-name"), - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - SecretsScanResults: &formats.SummaryCount{"Low": 1, "High": 2}, + }, + }, + } + testBuildScanSummary := ScanCommandResultSummary{ + ResultType: Build, + Args: &ResultSummaryArgs{ + BaseJfrogUrl: testPlatformUrl, + BuildName: "build-name", + BuildNumbers: []string{"build-number"}, + }, + Summary: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ + { + Target: "build-name (build-number)", + Violations: &formats.ScanViolationsSummary{ + Watches: []string{"watch"}, + ScanResultSummary: violationResults, + }, + }, + }, + }, + } + testCurationSummary := ScanCommandResultSummary{ + ResultType: Curation, + Summary: formats.ResultsSummary{ + Scans: []formats.ScanSummary{ + { + Target: filepath.Join("path", "to", "application"), + CuratedPackages: &formats.CuratedPackages{ + PackageCount: 6, + Blocked: []formats.BlockedPackages{ + { + Policy: "Malicious", + Condition: "Malicious package", + Packages: map[string]int{"npm://lodash:1.0.0": 1}, }, }, - { - Target: filepath.Join("other-root", "dir", "binary-name2"), - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{}, - }, - }}, + }, }, - ScanCommandSummaryResult{ - Section: Modules, - WorkingDirectory: wd, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{ + }, + }, + } + + testCases := []struct { + name string + content []ScanCommandResultSummary + filterSections []SecuritySummarySection + expectedArgs ResultSummaryArgs + expectedContent []formats.ResultsSummary + }{ + { + name: "Single scan", + content: []ScanCommandResultSummary{testDockerScanSummary}, + expectedArgs: *testDockerScanSummary.Args, + expectedContent: []formats.ResultsSummary{testDockerScanSummary.Summary}, + }, + { + name: "Multiple scans", + content: []ScanCommandResultSummary{testDockerScanSummary, testBinaryScanSummary, testBuildScanSummary}, + expectedArgs: ResultSummaryArgs{ + BaseJfrogUrl: testPlatformUrl, + DockerImage: "dockerImage:version", + BuildName: "build-name", + BuildNumbers: []string{"build-number"}, + }, + expectedContent: []formats.ResultsSummary{testDockerScanSummary.Summary, testBinaryScanSummary.Summary, testBuildScanSummary.Summary}, + }, + { + name: "Multiple scans with filter", + filterSections: []SecuritySummarySection{Curation}, + content: []ScanCommandResultSummary{testDockerScanSummary, testBinaryScanSummary, testBuildScanSummary, testCurationSummary}, + expectedContent: []formats.ResultsSummary{testCurationSummary.Summary}, + }, + } + tempDir, cleanUp := coreTests.CreateTempDirWithCallbackAndAssert(t) + defer cleanUp() + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + dataFilePaths := []string{} + // Save the data + for i := range testCase.content { + updateSummaryNamesToRelativePath(&testCase.content[i].Summary, tempDir) + data, err := JSONMarshal(&testCase.content[i]) + assert.NoError(t, err) + dataFilePath := filepath.Join(tempDir, fmt.Sprintf("data_%s_%d.json", testCase.name, i)) + assert.NoError(t, os.WriteFile(dataFilePath, data, 0644)) + dataFilePaths = append(dataFilePaths, dataFilePath) + } + // Load the data + loadedData, loadedArgs, err := loadContent(dataFilePaths, testCase.filterSections...) + assert.NoError(t, err) + assert.ElementsMatch(t, testCase.expectedContent, loadedData) + assert.Equal(t, testCase.expectedArgs, loadedArgs) + }) + } +} + +func TestGenerateJobSummaryMarkdown(t *testing.T) { + wd, err := os.Getwd() + assert.NoError(t, err) + testCases := []struct { + name string + index commandsummary.Index + args *ResultSummaryArgs + violations bool + content []formats.ResultsSummary + NoExtendedView bool + expectedContentPath string + }{ + { + name: "Security Section (Curation)", + expectedContentPath: filepath.Join(summaryExpectedContentDir, "security_section.md"), + content: []formats.ResultsSummary{ + { + Scans: []formats.ScanSummary{ { Target: filepath.Join(wd, "application1"), - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - SastScanResults: &formats.SummaryCount{"Low": 1}, - IacScanResults: &formats.SummaryCount{"Medium": 5}, - ScaScanResults: &formats.ScanScaResult{ - SummaryCount: formats.TwoLevelSummaryCount{ - "Critical": formats.SummaryCount{"Undetermined": 1, "Not Applicable": 2}, - "High": formats.SummaryCount{"Applicable": 1, "Not Applicable": 1, "Not Covered": 2}, - "Low": formats.SummaryCount{"Undetermined": 1}, + CuratedPackages: &formats.CuratedPackages{ + PackageCount: 6, + Blocked: []formats.BlockedPackages{ + { + Policy: "Malicious", + Condition: "Malicious package", + Packages: map[string]int{"npm://lodash:1.0.0": 1}, + }, + { + Policy: "cvss_score", + Condition: "cvss score higher than 4.0", + Packages: map[string]int{"npm://underscore:1.0.0": 1, "npm://test:2.0.0": 1}, }, - UniqueFindings: 6, }, }, }, { - Target: filepath.Join(wd, "application2"), - Violations: formats.TwoLevelSummaryCount{formats.ViolationTypeSecurity.String(): formats.SummaryCount{"High": 1}}, - Vulnerabilities: &formats.ScanVulnerabilitiesSummary{ - ScaScanResults: &formats.ScanScaResult{ - SummaryCount: formats.TwoLevelSummaryCount{"High": formats.SummaryCount{"Not Applicable": 1}}, - UniqueFindings: 1, - }, - }, + Target: filepath.Join(wd, "application2"), + CuratedPackages: &formats.CuratedPackages{PackageCount: 3}, }, - { - Target: filepath.Join(wd, "dir", "application3"), + }, + }, + { + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "application3"), + CuratedPackages: &formats.CuratedPackages{ + PackageCount: 5, + Blocked: []formats.BlockedPackages{{ + Policy: "Aged", + Condition: "Package is aged", + Packages: map[string]int{"npm://test:1.0.0": 1}, + }}, }, }}, }, - ScanCommandSummaryResult{ - Section: Curation, - WorkingDirectory: wd, - Results: formats.SummaryResults{Scans: []formats.ScanSummaryResult{ - { - Target: filepath.Join(wd, "application1"), - CuratedPackages: &formats.CuratedPackages{ - Blocked: formats.TwoLevelSummaryCount{ - "Policy: Malicious, Condition: Malicious package": formats.SummaryCount{"npm://lodash:1.0.0": 1}, - "Policy: cvss_score, Condition:cvss score higher than 4.0": formats.SummaryCount{"npm://underscore:1.0.0": 1}, - }, - Approved: 4, - }, + }, + }, + { + name: "No vulnerabilities", + index: commandsummary.BinariesScan, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "no_vulnerabilities.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "binary-name"), + Vulnerabilities: &formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ScanIds: []string{TestScaScanId}, MoreInfoUrls: []string{testMoreInfoUrl}}}, + }}, + }}, + }, + { + name: "Violations - Not defined", + index: commandsummary.BinariesScan, + violations: true, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "violations_not_defined.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "binary-name"), + Vulnerabilities: &formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ScanIds: []string{TestScaScanId}}}, + }}, + }}, + }, + { + name: "No violations", + index: commandsummary.BinariesScan, + violations: true, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "no_violations.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "other-binary-name"), + Violations: &formats.ScanViolationsSummary{ + Watches: []string{}, + ScanResultSummary: formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ScanIds: []string{TestScaScanId}, MoreInfoUrls: []string{testMoreInfoUrl}}}, + }, + }}, + }}, + }, + { + name: "Build Scan Vulnerabilities", + index: commandsummary.BuildScan, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "build_scan_vulnerabilities.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl, BuildName: "build-name", BuildNumbers: []string{"build-number"}}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: "build-name (build-number)", + Vulnerabilities: &formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{testMoreInfoUrl}, + Security: formats.ResultSummary{"High": map[string]int{formats.NoStatus: 3}, "Medium": map[string]int{formats.NoStatus: 1}, "Unknown": map[string]int{formats.NoStatus: 20}}, + }}, + }}, + }}, + }, + { + name: "Binary Scan Vulnerabilities", + index: commandsummary.BinariesScan, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "binary_vulnerabilities.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "binary-with-issues"), + Vulnerabilities: &formats.ScanResultSummary{ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId, "scan-id-2"}, + MoreInfoUrls: []string{""}, + Security: formats.ResultSummary{"Critical": map[string]int{formats.NoStatus: 33}, "Low": map[string]int{formats.NoStatus: 11}}, + }}, + }}, + }}, + }, + { + name: "Docker Scan Vulnerabilities", + index: commandsummary.DockerScan, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "docker_vulnerabilities.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl, DockerImage: "dockerImage:version"}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "image.tar"), + Vulnerabilities: &formats.ScanResultSummary{ + ScaResults: &formats.ScaScanResultSummary{ + ScanIds: []string{TestScaScanId}, + MoreInfoUrls: []string{""}, + Security: securityScaResults, }, - { - Target: filepath.Join(wd, "application2"), - CuratedPackages: &formats.CuratedPackages{ - Blocked: formats.TwoLevelSummaryCount{ - "Policy: License, Condition: GPL": formats.SummaryCount{"npm://test:1.0.0": 1}, - "Policy: Aged, Condition: Package is aged": formats.SummaryCount{"npm://test2:1.0.0": 1}, - }, - Approved: 4, - }, + SecretsResults: &formats.ResultSummary{ + "Medium": map[string]int{formats.NoStatus: 3}, }, }, + }}, + }}, + }, + { + name: "Violations", + index: commandsummary.DockerScan, + violations: true, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "violations.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl, DockerImage: "dockerImage:version"}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "image.tar"), + Violations: &formats.ScanViolationsSummary{ + Watches: []string{"watch1", "watch2", "watch3", "watch4", "watch5"}, + ScanResultSummary: violationResults, }, - }, - ), - expectedContentPath: filepath.Join(summaryExpectedContentDir, "multi_command_job.md"), + }}, + }}, + }, + { + name: "Violations - Not extendedView", + index: commandsummary.DockerScan, + violations: true, + NoExtendedView: true, + expectedContentPath: filepath.Join(summaryExpectedContentDir, "violations_not_extended_view.md"), + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl, DockerImage: "dockerImage:version"}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "image.tar"), + Violations: &formats.ScanViolationsSummary{ + Watches: []string{"watch1"}, + ScanResultSummary: violationResults, + }, + }}, + }}, + }, + { + name: "Vulnerability not requested", + index: commandsummary.DockerScan, + args: &ResultSummaryArgs{BaseJfrogUrl: testPlatformUrl, DockerImage: "dockerImage:version"}, + content: []formats.ResultsSummary{{ + Scans: []formats.ScanSummary{{ + Target: filepath.Join(wd, "image.tar"), + }}, + }}, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - // Read expected content from file - expectedContent := getOutputFromFile(t, testCase.expectedContentPath) - summary, err := ConvertSummaryToString(testCase.summary) + // Read expected content from file (or empty string expected if no file is provided) + expectedContent := "" + if testCase.expectedContentPath != "" { + expectedContent = getOutputFromFile(t, testCase.expectedContentPath) + } + for i := range testCase.content { + updateSummaryNamesToRelativePath(&testCase.content[i], wd) + } + var summary string + var err error + // Generate the summary + if testCase.index == "" { + summary, err = GenerateSecuritySectionMarkdown(testCase.content) + // Replace all backslashes with forward slashes for Windows compatibility in tests + summary = strings.ReplaceAll(summary, string(filepath.Separator), "/") + } else { + assert.NotNil(t, testCase.args) + summary, err = createDummyDynamicMarkdown(testCase.content, testCase.index, *testCase.args, testCase.violations, !testCase.NoExtendedView) + } assert.NoError(t, err) assert.Equal(t, expectedContent, summary) }) } } +func createDummyDynamicMarkdown(content []formats.ResultsSummary, index commandsummary.Index, args ResultSummaryArgs, violations, extendedView bool) (markdown string, err error) { + securityJobSummary := &SecurityJobSummary{} + var generator DynamicMarkdownGenerator + switch index { + case commandsummary.BuildScan: + generator, err = securityJobSummary.BuildScan([]string{}) + case commandsummary.DockerScan: + generator, err = securityJobSummary.DockerScan([]string{}) + case commandsummary.BinariesScan: + generator, err = securityJobSummary.BinaryScan([]string{}) + } + if err != nil { + return + } + generator.extendedView = extendedView + generator.args = args + generator.content = content + if violations { + markdown = generator.GetViolations() + } else { + markdown = generator.GetVulnerabilities() + } + return +} + func getOutputFromFile(t *testing.T, path string) string { content, err := os.ReadFile(path) assert.NoError(t, err) - return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(string(content), "\r\n", "\n"), "/", string(filepath.Separator)), "<"+string(filepath.Separator), "