diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index f6836c5c..09b48796 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/jfrog/jfrog-cli-security/formats" "net/http" "os" "path/filepath" @@ -175,6 +176,11 @@ type CurationAuditCommand struct { utils.AuditParams } +type CurationReport struct { + packagesStatus []*PackageStatus + totalNumberOfPackages int +} + func NewCurationAuditCommand() *CurationAuditCommand { return &CurationAuditCommand{ extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), @@ -182,9 +188,8 @@ func NewCurationAuditCommand() *CurationAuditCommand { } } -func (ca *CurationAuditCommand) setPackageManagerConfig(pkgMangerConfig *project.RepositoryConfig) *CurationAuditCommand { +func (ca *CurationAuditCommand) setPackageManagerConfig(pkgMangerConfig *project.RepositoryConfig) { ca.PackageManagerConfig = pkgMangerConfig - return ca } func (ca *CurationAuditCommand) SetWorkingDirs(dirs []string) *CurationAuditCommand { @@ -211,7 +216,7 @@ func (ca *CurationAuditCommand) Run() (err error) { } else { ca.workingDirs = append(ca.workingDirs, rootDir) } - results := map[string][]*PackageStatus{} + results := map[string]*CurationReport{} for _, workDir := range ca.workingDirs { var absWd string absWd, err = filepath.Abs(workDir) @@ -234,12 +239,54 @@ func (ca *CurationAuditCommand) Run() (err error) { } for projectPath, packagesStatus := range results { - err = errors.Join(err, printResult(ca.OutputFormat(), projectPath, packagesStatus)) + err = errors.Join(err, printResult(ca.OutputFormat(), projectPath, packagesStatus.packagesStatus)) } + + err = errors.Join(err, utils.RecordSecurityCommandOutput(utils.ScanCommandSummaryResult{Results: convertResultsToSummary(results), Section: utils.Curation})) return } -func (ca *CurationAuditCommand) doCurateAudit(results map[string][]*PackageStatus) error { +func convertResultsToSummary(results map[string]*CurationReport) formats.SummaryResults { + summaryResults := formats.SummaryResults{} + for projectPath, packagesStatus := range results { + blocked := convertBlocked(packagesStatus.packagesStatus) + approved := packagesStatus.totalNumberOfPackages - blocked.GetCountOfKeys(false) + + summaryResults.Scans = append(summaryResults.Scans, formats.ScanSummaryResult{Target: projectPath, + CuratedPackages: &formats.CuratedPackages{ + Blocked: blocked, + Approved: approved, + }}) + } + return summaryResults +} + +func convertBlocked(pkgStatus []*PackageStatus) formats.TwoLevelSummaryCount { + blocked := formats.TwoLevelSummaryCount{} + for _, pkg := range pkgStatus { + for _, policy := range pkg.Policy { + polAndCond := formatPolicyAndCond(policy.Policy, policy.Condition) + if _, ok := blocked[polAndCond]; !ok { + blocked[polAndCond] = formats.SummaryCount{} + } + uniqId := uniqPkgAppearanceId(pkg.ParentName, pkg.ParentVersion, pkg.PackageName, pkg.PackageVersion) + blocked[polAndCond][uniqId]++ + } + } + return blocked +} + +func formatPolicyAndCond(policy, cond string) string { + return fmt.Sprintf("Policy: %s, Condition: %s", policy, cond) +} + +// The unique identifier of a package includes both the package name with its version and the parent package with its version +func uniqPkgAppearanceId(parentName, parentVersion, packageName, packageVersion string) string { + return fmt.Sprintf("%s:%s-%s:%s", + parentName, parentVersion, packageName, packageVersion) +} + +func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { techs := techutils.DetectedTechnologiesList() for _, tech := range techs { supportedFunc, ok := supportedTech[techutils.Technology(tech)] @@ -259,6 +306,10 @@ func (ca *CurationAuditCommand) doCurateAudit(results map[string][]*PackageStatu if err := ca.auditTree(techutils.Technology(tech), results); err != nil { return err } + // clear the package manager config to avoid using the same config for the next tech + ca.setPackageManagerConfig(nil) + ca.AuditParams = ca.SetDepsRepo("") + } return nil } @@ -293,7 +344,7 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech techutils.Technology) return ca.AuditParams } -func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map[string][]*PackageStatus) error { +func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map[string]*CurationReport) error { params := ca.getAuditParamsByTech(tech) serverDetails, err := audit.SetResolutionRepoIfExists(params, tech) if err != nil { @@ -364,7 +415,11 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map sort.Slice(packagesStatus, func(i, j int) bool { return packagesStatus[i].ParentName < packagesStatus[j].ParentName }) - results[strings.TrimSuffix(fmt.Sprintf("%s:%s", projectName, projectVersion), ":")] = packagesStatus + results[strings.TrimSuffix(fmt.Sprintf("%s:%s", projectName, projectVersion), ":")] = &CurationReport{ + packagesStatus: packagesStatus, + // We subtract 1 because the root node is not a package. + totalNumberOfPackages: len(depTreeResult.FlatTree.Nodes) - 1, + } return err } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 42c5980e..3beb3e91 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -3,6 +3,7 @@ package curation import ( "encoding/json" "fmt" + "github.com/jfrog/jfrog-cli-security/formats" "net/http" "net/http/httptest" "os" @@ -456,7 +457,7 @@ func TestDoCurationAudit(t *testing.T) { curationCmd.SetIsCurationCmd(true) curationCmd.parallelRequests = 3 curationCmd.SetIgnoreConfigFile(tt.shouldIgnoreConfigFile) - results := map[string][]*PackageStatus{} + results := map[string]*CurationReport{} if tt.requestToError == nil { assert.NoError(t, curationCmd.doCurateAudit(results)) } else { @@ -476,8 +477,17 @@ func TestDoCurationAudit(t *testing.T) { // Add the mock server to the expected blocked message url for key := range tt.expectedResp { - for index := range tt.expectedResp[key] { - tt.expectedResp[key][index].BlockedPackageUrl = fmt.Sprintf("%s%s", strings.TrimSuffix(config.GetArtifactoryUrl(), "/"), tt.expectedResp[key][index].BlockedPackageUrl) + for index := range tt.expectedResp[key].packagesStatus { + tt.expectedResp[key].packagesStatus[index].BlockedPackageUrl = fmt.Sprintf("%s%s", + strings.TrimSuffix(config.GetArtifactoryUrl(), "/"), + tt.expectedResp[key].packagesStatus[index].BlockedPackageUrl) + } + } + // the number of packages is not deterministic for pip, as it depends on the version of the package manager. + if tt.tech == techutils.Pip { + for key := range results { + result := results[key] + result.totalNumberOfPackages = 0 } } assert.Equal(t, tt.expectedResp, results) @@ -502,10 +512,11 @@ type testCase struct { expectedBuildRequest map[string]bool expectedRequest map[string]bool requestToFail map[string]bool - expectedResp map[string][]*PackageStatus + expectedResp map[string]*CurationReport requestToError map[string]bool expectedError string cleanDependencies func() error + tech techutils.Technology createServerWithoutCreds bool } @@ -513,6 +524,7 @@ func getTestCasesForDoCurationAudit() []testCase { tests := []testCase{ { name: "go tree - one blocked package", + tech: techutils.Go, pathToTest: filepath.Join(TestDataDir, "projects", "package-managers", "go", "curation-project", ".jfrog"), createServerWithoutCreds: true, serveResources: map[string]string{ @@ -529,24 +541,25 @@ func getTestCasesForDoCurationAudit() []testCase { requestToFail: map[string]bool{ "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, }, - expectedResp: map[string][]*PackageStatus{ - "github.com/you/hello": {{ - Action: "blocked", - ParentName: "rsc.io/quote", - ParentVersion: "v1.5.2", - BlockedPackageUrl: "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip", - PackageName: "rsc.io/sampler", - PackageVersion: "v1.3.0", - BlockingReason: "Policy violations", - DepRelation: "indirect", - PkgType: "go", - Policy: []Policy{ - { - Policy: "pol1", - Condition: "cond1", + expectedResp: map[string]*CurationReport{ + "github.com/you/hello": {packagesStatus: []*PackageStatus{ + { + Action: "blocked", + ParentName: "rsc.io/quote", + ParentVersion: "v1.5.2", + BlockedPackageUrl: "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip", + PackageName: "rsc.io/sampler", + PackageVersion: "v1.3.0", + BlockingReason: "Policy violations", + DepRelation: "indirect", + PkgType: "go", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, }, }, - }, { Action: "blocked", ParentName: "rsc.io/sampler", @@ -565,10 +578,14 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, + totalNumberOfPackages: 3, + }, }, }, + { name: "python tree - one blocked package", + tech: techutils.Pip, pathToTest: filepath.Join(TestDataDir, "projects", "package-managers", "python", "pip", "pip-curation", ".jfrog"), serveResources: map[string]string{ "pip": filepath.Join("resources", "pip-resp"), @@ -580,8 +597,8 @@ func getTestCasesForDoCurationAudit() []testCase { requestToFail: map[string]bool{ "/api/pypi/pypi-remote/packages/packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl": false, }, - expectedResp: map[string][]*PackageStatus{ - "pip-curation": { + expectedResp: map[string]*CurationReport{ + "pip-curation": &CurationReport{packagesStatus: []*PackageStatus{ { Action: "blocked", ParentVersion: "4.8.0", @@ -600,10 +617,12 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, + }, }, }, { name: "maven tree - one blocked package", + tech: techutils.Maven, pathToPreTest: filepath.Join(TestDataDir, "projects", "package-managers", "maven", "maven-curation", "pretest"), preTestExec: "mvn", funcToGetGoals: func(t *testing.T) []string { @@ -627,8 +646,8 @@ func getTestCasesForDoCurationAudit() []testCase { requestToFail: map[string]bool{ "/maven-remote/org/webjars/npm/underscore/1.13.6/underscore-1.13.6.jar": false, }, - expectedResp: map[string][]*PackageStatus{ - "test:my-app:1.0.0": { + expectedResp: map[string]*CurationReport{ + "test:my-app:1.0.0": &CurationReport{packagesStatus: []*PackageStatus{ { Action: "blocked", ParentVersion: "1.13.6", @@ -647,12 +666,15 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, + totalNumberOfPackages: 2, + }, }, requestToError: nil, expectedError: "", }, { name: "npm tree - two blocked package ", + tech: techutils.Npm, pathToTest: filepath.Join(TestDataDir, "projects", "package-managers", "npm", "npm-project", ".jfrog"), shouldIgnoreConfigFile: true, expectedRequest: map[string]bool{ @@ -662,8 +684,8 @@ func getTestCasesForDoCurationAudit() []testCase { requestToFail: map[string]bool{ "/api/npm/npms/underscore/-/underscore-1.13.6.tgz": false, }, - expectedResp: map[string][]*PackageStatus{ - "npm_test:1.0.0": { + expectedResp: map[string]*CurationReport{ + "npm_test:1.0.0": &CurationReport{packagesStatus: []*PackageStatus{ { Action: "blocked", ParentVersion: "1.13.6", @@ -682,10 +704,13 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, + totalNumberOfPackages: 2, + }, }, }, { name: "npm tree - two blocked one error", + tech: techutils.Npm, pathToTest: filepath.Join(TestDataDir, "projects", "package-managers", "npm", "npm-project", ".jfrog"), shouldIgnoreConfigFile: true, expectedRequest: map[string]bool{ @@ -698,8 +723,8 @@ func getTestCasesForDoCurationAudit() []testCase { requestToError: map[string]bool{ "/api/npm/npms/lightweight/-/lightweight-0.1.0.tgz": false, }, - expectedResp: map[string][]*PackageStatus{ - "npm_test:1.0.0": { + expectedResp: map[string]*CurationReport{ + "npm_test:1.0.0": &CurationReport{packagesStatus: []*PackageStatus{ { Action: "blocked", ParentVersion: "1.13.6", @@ -718,6 +743,8 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, + totalNumberOfPackages: 2, + }, }, expectedError: fmt.Sprintf("failed sending HEAD request to %s for package '%s'. Status-code: %v. "+ "Cause: executor timeout after 2 attempts with 0 milliseconds wait intervals", @@ -825,3 +852,133 @@ func Test_getGoNameScopeAndVersion(t *testing.T) { }) } } + +func Test_convertResultsToSummary(t *testing.T) { + tests := []struct { + name string + input map[string]*CurationReport + expected formats.SummaryResults + }{ + { + name: "results for one result", + input: map[string]*CurationReport{ + "project1": { + packagesStatus: []*PackageStatus{ + { + PackageName: "test1", + PackageVersion: "1.0.0", + ParentVersion: "1.0.0", + ParentName: "parent-test1", + + Action: "blocked", + Policy: []Policy{ + { + Policy: "policy1", + Condition: "cond1", + }, + }, + }, + }, + totalNumberOfPackages: 5, + }, + }, + expected: formats.SummaryResults{ + Scans: []formats.ScanSummaryResult{ + { + Target: "project1", + CuratedPackages: &formats.CuratedPackages{ + Blocked: formats.TwoLevelSummaryCount{ + formatPolicyAndCond("policy1", "cond1"): formats.SummaryCount{ + uniqPkgAppearanceId("parent-test1", "1.0.0", "test1", "1.0.0"): 1, + }, + }, + Approved: 4, + }, + }, + }, + }, + }, + { + name: "results for three result - aggregate one, same component in two policies", + input: map[string]*CurationReport{ + "project1": { + packagesStatus: []*PackageStatus{ + { + PackageName: "test1", + PackageVersion: "1.0.0", + ParentVersion: "1.0.0", + ParentName: "parent-test1", + + Action: "blocked", + Policy: []Policy{ + { + Policy: "policy1", + Condition: "cond1", + }, + { + Policy: "policy2", + Condition: "cond2", + }, + }, + }, + { + PackageName: "test2", + PackageVersion: "2.0.0", + ParentVersion: "2.0.0", + ParentName: "parent-test2", + + Action: "blocked", + Policy: []Policy{ + { + Policy: "policy2", + Condition: "cond2", + }, + }, + }, + { + PackageName: "test3", + PackageVersion: "3.0.0", + ParentVersion: "3.0.0", + ParentName: "parent-test3", + + Action: "blocked", + Policy: []Policy{ + { + Policy: "policy2", + Condition: "cond2", + }, + }, + }, + }, + totalNumberOfPackages: 5, + }, + }, + expected: formats.SummaryResults{ + Scans: []formats.ScanSummaryResult{ + { + Target: "project1", + CuratedPackages: &formats.CuratedPackages{ + Blocked: formats.TwoLevelSummaryCount{ + formatPolicyAndCond("policy1", "cond1"): formats.SummaryCount{ + uniqPkgAppearanceId("parent-test1", "1.0.0", "test1", "1.0.0"): 1, + }, + formatPolicyAndCond("policy2", "cond2"): formats.SummaryCount{ + uniqPkgAppearanceId("parent-test1", "1.0.0", "test1", "1.0.0"): 1, + uniqPkgAppearanceId("parent-test2", "2.0.0", "test2", "2.0.0"): 1, + uniqPkgAppearanceId("parent-test3", "3.0.0", "test3", "3.0.0"): 1, + }, + }, + Approved: 2, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := convertResultsToSummary(tt.input) + assert.Equal(t, tt.expected, results) + }) + } +} diff --git a/formats/summary.go b/formats/summary.go index 420a6177..159027bc 100644 --- a/formats/summary.go +++ b/formats/summary.go @@ -1,5 +1,9 @@ package formats +import ( + "github.com/jfrog/gofrog/datastructures" +) + const ( ScaScan SummarySubScanType = "SCA" IacScan SummarySubScanType = "IAC" @@ -33,6 +37,12 @@ type ScanSummaryResult struct { Target string `json:"target,omitempty"` Vulnerabilities *ScanVulnerabilitiesSummary `json:"vulnerabilities,omitempty"` Violations TwoLevelSummaryCount `json:"violations,omitempty"` + CuratedPackages *CuratedPackages `json:"curated,omitempty"` +} + +type CuratedPackages struct { + Blocked TwoLevelSummaryCount `json:"blocked,omitempty"` + Approved int `json:"approved,omitempty"` } type ScanVulnerabilitiesSummary struct { @@ -48,7 +58,7 @@ type ScanScaResult struct { } func (s *ScanSummaryResult) HasIssues() bool { - return s.HasViolations() || s.HasSecurityVulnerabilities() + return s.HasViolations() || s.HasSecurityVulnerabilities() || s.HasBlockedCuration() } func (s *ScanSummaryResult) HasViolations() bool { @@ -59,6 +69,10 @@ func (s *ScanSummaryResult) HasSecurityVulnerabilities() bool { return s.Vulnerabilities != nil && s.Vulnerabilities.GetTotalIssueCount() > 0 } +func (s *ScanSummaryResult) HasBlockedCuration() bool { + return s.CuratedPackages != nil && s.CuratedPackages.Blocked.GetTotal() > 0 +} + func (s *ScanSummaryResult) GetTotalIssueCount() (total int) { if s.Vulnerabilities != nil { total += s.Vulnerabilities.GetTotalIssueCount() @@ -80,6 +94,10 @@ func (s *ScanVulnerabilitiesSummary) GetTotalIssueCount() (total int) { return s.getTotalIssueCount(false) } +func (s *CuratedPackages) GetTotalPackages() int { + return s.Approved + s.Blocked.GetTotal() +} + func (s *ScanVulnerabilitiesSummary) getTotalIssueCount(unique bool) (total int) { if s.ScaScanResults != nil { if unique { @@ -161,3 +179,16 @@ func (sc TwoLevelSummaryCount) GetCombinedLowerLevel() (oneLvlCounts SummaryCoun } return } + +func (sc TwoLevelSummaryCount) GetCountOfKeys(firstLevel bool) int { + if firstLevel { + return len(sc) + } + count := datastructures.MakeSet[string]() + for _, value := range sc { + for key := range value { + count.Add(key) + } + } + return count.Size() +} diff --git a/tests/testdata/other/jobSummary/multi_command_job.md b/tests/testdata/other/jobSummary/multi_command_job.md index 97c67d60..5ee6abd6 100644 --- a/tests/testdata/other/jobSummary/multi_command_job.md +++ b/tests/testdata/other/jobSummary/multi_command_job.md @@ -13,4 +13,9 @@ |--------|----|---------| | ❌ | /application1 |
Security Vulnerabilities: 14 (12 unique)| | ❌ | /application2 |
├── 1 SAST 🟡 1 Low
├── 5 IAC 🟠 5 Medium
└── 8 SCA ❗️ 3 Critical (2 Not Applicable)
🔴 4 High (1 Applicable, 1 Not Applicable)
🟡 1 Low
Violations: 1 - (1 Security)| -| ✅ | /dir/application3 | | \ No newline at end of file +| ✅ | /dir/application3 | | +#### Curation +| Status | Id | Details | +|--------|----|---------| +| ❌ | /application1 |
Security Vulnerabilities: 1 (1 unique)
└── 1 SCA 🔴 1 High (1 Not Applicable)
Total number of packages: 6| +| ❌ | /application2 |
🟢 Total Number of Approved: 4
🔴 Total Number of Blocked: 2
├── Policy: cvss_score, Condition:cvss score higher than 4.0 (1)
└── Policy: Malicious, Condition: Malicious package (1)
Total number of packages: 6| \ No newline at end of file diff --git a/utils/securityJobSummary.go b/utils/securityJobSummary.go index 136d1b1b..8493fe53 100644 --- a/utils/securityJobSummary.go +++ b/utils/securityJobSummary.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "path/filepath" + "sort" "strings" "github.com/jfrog/gofrog/datastructures" @@ -19,9 +20,10 @@ import ( ) const ( - Build SecuritySummarySection = "Builds" - Binary SecuritySummarySection = "Artifacts" - Modules SecuritySummarySection = "Modules" + Build SecuritySummarySection = "Builds" + Binary SecuritySummarySection = "Artifacts" + Modules SecuritySummarySection = "Modules" + Curation SecuritySummarySection = "Curation" ) type SecuritySummarySection string @@ -36,6 +38,7 @@ type SecurityCommandsSummary struct { BuildScanCommands []formats.SummaryResults `json:"buildScanCommands"` ScanCommands []formats.SummaryResults `json:"scanCommands"` AuditCommands []formats.SummaryResults `json:"auditCommands"` + CurationCommands []formats.SummaryResults `json:"curationCommands"` } // Manage the job summary for security commands @@ -85,6 +88,8 @@ func loadContentFromFiles(dataFilePaths []string, scs *SecurityCommandsSummary) scs.ScanCommands = append(scs.ScanCommands, results) case Modules: scs.AuditCommands = append(scs.AuditCommands, results) + case Curation: + scs.CurationCommands = append(scs.CurationCommands, results) } } return @@ -100,6 +105,9 @@ func (scs *SecurityCommandsSummary) GetOrderedSectionsWithContent() (sections [] if len(scs.AuditCommands) > 0 { sections = append(sections, Modules) } + if len(scs.CurationCommands) > 0 { + sections = append(sections, Curation) + } return } @@ -112,6 +120,8 @@ func (scs *SecurityCommandsSummary) getSectionSummaries(section SecuritySummaryS summaries = scs.ScanCommands case Modules: summaries = scs.AuditCommands + case Curation: + summaries = scs.CurationCommands } return } @@ -179,6 +189,10 @@ func GetScanSummaryString(summary formats.ScanSummaryResult, singleData bool) (c } 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 := "" @@ -188,6 +202,38 @@ func getDetailsString(summary formats.ScanSummaryResult) string { return violationContent + delimiter + vulnerabilitiesContent } +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: 4
🔴 Total Number of Blocked: 2
├── Policy: License, Condition: GPL (1)
└── Policy: Aged, Condition: Package is aged (1)