From 5376364549273497b09c622f77962c8aa7732822 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 30 Mar 2026 11:57:23 +0300 Subject: [PATCH 1/4] save results in gitlab format --- scanrepository/scanrepository.go | 11 +- utils/consts.go | 9 +- utils/getconfiguration.go | 16 +- utils/gitlabreport/gitlabreport.go | 334 +++++++++++++++++++++++++++++ utils/utils.go | 57 ++++- 5 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 utils/gitlabreport/gitlabreport.go diff --git a/scanrepository/scanrepository.go b/scanrepository/scanrepository.go index 379c45d29..88bc5e68a 100644 --- a/scanrepository/scanrepository.go +++ b/scanrepository/scanrepository.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" - "github.com/jfrog/frogbot/v2/packageupdaters" "os" "path/filepath" "regexp" "strings" + "github.com/jfrog/frogbot/v2/packageupdaters" + "github.com/go-git/go-git/v5" biutils "github.com/jfrog/build-info-go/utils" @@ -154,6 +155,14 @@ func (sr *ScanRepositoryCmd) scanAndFixBranch(repository *utils.Repository) (tot totalFindings = getTotalFindingsFromScanResults(scanResults) sr.uploadResultsToGithubDashboardsIfNeeded(repository, scanResults) + if repository.Params.Git.GitProvider == vcsutils.GitLab && repository.Params.Git.GitlabScanResultsOutputDir != "" { + log.Debug(fmt.Sprintf("Trying to save scan results to directory: %s", repository.Params.Git.GitlabScanResultsOutputDir)) + if err = utils.WriteScanResultsToDir(repository.Params.Git.GitlabScanResultsOutputDir, scanResults, sr.scanDetails.StartTime); err != nil { + log.Warn(fmt.Sprintf("Failed to write scan results to directory: %s", err.Error())) + } + return + } + if !repository.Params.FrogbotConfig.CreateAutoFixPr { log.Info(fmt.Sprintf("This command is running in detection mode only. To enable automatic fixing of issues, set the '%s' flag under the repository's coniguration settings in Jfrog platform", createAutoFixPrConfigNameInProfile)) return totalFindings, nil diff --git a/utils/consts.go b/utils/consts.go index f124f9e1a..d56674008 100644 --- a/utils/consts.go +++ b/utils/consts.go @@ -42,10 +42,11 @@ const ( GitDependencyGraphSubmissionEnv = "JF_UPLOAD_SBOM_TO_VCS" //#nosec G101 -- False positive - no hardcoded credentials. - GitTokenEnv = "JF_GIT_TOKEN" - GitBaseBranchEnv = "JF_GIT_BASE_BRANCH" - GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID" - GitApiEndpointEnv = "JF_GIT_API_ENDPOINT" + GitTokenEnv = "JF_GIT_TOKEN" + GitBaseBranchEnv = "JF_GIT_BASE_BRANCH" + GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID" + GitApiEndpointEnv = "JF_GIT_API_ENDPOINT" + GitlabScanResultsOutputDirEnv = "JF_SCAN_RESULTS_OUTPUT_DIR" // Placeholders for templates PackagePlaceHolder = "{IMPACTED_PACKAGE}" diff --git a/utils/getconfiguration.go b/utils/getconfiguration.go index b4941e6c9..6e39774c2 100644 --- a/utils/getconfiguration.go +++ b/utils/getconfiguration.go @@ -68,12 +68,13 @@ func (jp *JFrogPlatform) setJfProjectKeyIfExists() (err error) { type Git struct { GitProvider vcsutils.VcsProvider vcsclient.VcsInfo - RepoOwner string - RepoName string - Branches []string - PullRequestDetails vcsclient.PullRequestInfo - RepositoryCloneUrl string - UploadSbomToVcs *bool + RepoOwner string + RepoName string + Branches []string + PullRequestDetails vcsclient.PullRequestInfo + RepositoryCloneUrl string + UploadSbomToVcs *bool + GitlabScanResultsOutputDir string } func (g *Git) GetRepositoryHttpsCloneUrl(gitClient vcsclient.VcsClient) (string, error) { @@ -95,6 +96,7 @@ func (g *Git) setDefaultsIfNeeded(gitParamsFromEnv *Git, commandName string) (er g.VcsInfo = gitParamsFromEnv.VcsInfo g.PullRequestDetails = gitParamsFromEnv.PullRequestDetails g.RepoName = gitParamsFromEnv.RepoName + g.GitlabScanResultsOutputDir = gitParamsFromEnv.GitlabScanResultsOutputDir if commandName == ScanPullRequest { if gitParamsFromEnv.PullRequestDetails.ID == 0 { @@ -425,6 +427,8 @@ func extractGitParamsFromEnvs() (*Git, error) { gitEnvParams.PullRequestDetails = vcsclient.PullRequestInfo{ID: int64(convertedPrId)} } + gitEnvParams.GitlabScanResultsOutputDir = getTrimmedEnv(GitlabScanResultsOutputDirEnv) + return gitEnvParams, nil } diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go new file mode 100644 index 000000000..0667e0201 --- /dev/null +++ b/utils/gitlabreport/gitlabreport.go @@ -0,0 +1,334 @@ +package gitlabreport + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/results" + "github.com/jfrog/jfrog-cli-security/utils/results/conversion" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + gitLabReportSchemaVersion = "15.2.4" + gitLabReportSchemaURL = "https://gitlab.com/gitlab-org/security-products/security-report-schemas/-/raw/master/dist/dependency-scanning-report-format.json" + frogbotAnalyzerID = "frogbot-dependency-scanning" + frogbotAnalyzerName = "JFrog Frogbot" + frogbotVendorName = "JFrog" +) + +type DependencyScanningReport struct { + Scan ScanReport `json:"scan"` + Schema string `json:"schema,omitempty"` + Version string `json:"version"` + Vulnerabilities []VulnerabilityReport `json:"vulnerabilities"` +} + +type ScanReport struct { + Analyzer AnalyzerScanner `json:"analyzer"` + Scanner AnalyzerScanner `json:"scanner"` + StartTime string `json:"start_time"` // ISO8601 UTC yyyy-mm-ddThh:mm:ss + EndTime string `json:"end_time"` + Status string `json:"status"` // "success" or "failure" + Type string `json:"type"` // "dependency_scanning" +} + +type AnalyzerScanner struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Vendor Vendor `json:"vendor"` + URL string `json:"url,omitempty"` +} + +type Vendor struct { + Name string `json:"name"` +} + +type VulnerabilityReport struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Severity string `json:"severity,omitempty"` // Info, Unknown, Low, Medium, High, Critical + Solution string `json:"solution,omitempty"` + Identifiers []Identifier `json:"identifiers"` + Location Location `json:"location"` + Links []Link `json:"links,omitempty"` +} + +type Identifier struct { + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + URL string `json:"url,omitempty"` +} + +type Location struct { + File string `json:"file"` + Dependency Dependency `json:"dependency"` +} + +type Dependency struct { + Package Package `json:"package"` + Version string `json:"version"` + Direct *bool `json:"direct,omitempty"` +} + +type Package struct { + Name string `json:"name"` +} + +type Link struct { + Name string `json:"name,omitempty"` + URL string `json:"url"` +} + +func ConvertToGitLabDependencyScanningReport(scanResults *results.SecurityCommandResults, startTime, endTime time.Time, frogbotVersion string) (*DependencyScanningReport, error) { + if scanResults == nil { + return &DependencyScanningReport{ + Scan: ScanReport{ + Analyzer: makeAnalyzerScanner(frogbotVersion), + Scanner: makeAnalyzerScanner(frogbotVersion), + StartTime: formatGitLabTime(startTime), + EndTime: formatGitLabTime(endTime), + Status: "success", + Type: "dependency_scanning", + }, + Version: gitLabReportSchemaVersion, + Schema: gitLabReportSchemaURL, + Vulnerabilities: []VulnerabilityReport{}, + }, nil + } + + convertor := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ + IncludeVulnerabilities: scanResults.IncludesVulnerabilities(), + HasViolationContext: scanResults.HasViolationContext(), + }) + simpleJSON, err := convertor.ConvertToSimpleJson(scanResults) + if err != nil { + return nil, fmt.Errorf("convert to simple json: %w", err) + } + + var vulns []formats.VulnerabilityOrViolationRow + vulns = append(vulns, simpleJSON.Vulnerabilities...) + vulns = append(vulns, simpleJSON.SecurityViolations...) + + reports := make([]VulnerabilityReport, 0, len(vulns)) + seen := make(map[string]struct{}) + + for i := range vulns { + v := &vulns[i] + key := v.ImpactedDependencyName + "|" + v.ImpactedDependencyVersion + "|" + v.IssueId + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + report := vulnerabilityToReport(v) + reports = append(reports, report) + } + + status := "success" + if err = scanResults.GetErrors(); err != nil { + status = "failure" + } + + return &DependencyScanningReport{ + Scan: ScanReport{ + Analyzer: makeAnalyzerScanner(frogbotVersion), + Scanner: makeAnalyzerScanner(frogbotVersion), + StartTime: formatGitLabTime(startTime), + EndTime: formatGitLabTime(endTime), + Status: status, + Type: "dependency_scanning", + }, + Schema: gitLabReportSchemaURL, + Version: gitLabReportSchemaVersion, + Vulnerabilities: reports, + }, nil +} + +func makeAnalyzerScanner(version string) AnalyzerScanner { + if version == "" { + version = "0.0.0" + } + return AnalyzerScanner{ + ID: frogbotAnalyzerID, + Name: frogbotAnalyzerName, + Version: version, + Vendor: Vendor{Name: frogbotVendorName}, + URL: "https://github.com/jfrog/frogbot", + } +} + +func formatGitLabTime(t time.Time) string { + return t.UTC().Format("2006-01-02T15:04:05") +} + +func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) VulnerabilityReport { + id := deterministicVulnID(v.ImpactedDependencyName, v.ImpactedDependencyVersion, v.IssueId, v.Cves) + identifiers := buildIdentifiers(v) + location := Location{ + File: manifestFileForTechnology(v.Technology), + Dependency: Dependency{ + Package: Package{Name: v.ImpactedDependencyName}, + Version: v.ImpactedDependencyVersion, + }, + } + severity := normalizeSeverity(getSeverity(v)) + name := v.IssueId + if len(v.Cves) > 0 { + name = v.Cves[0].Id + } + desc := getSummary(v) + solution := "" + if len(v.FixedVersions) > 0 { + solution = fmt.Sprintf("Upgrade %s to version %s or later.", v.ImpactedDependencyName, v.FixedVersions[0]) + } + var links []Link + for _, cve := range v.Cves { + if cve.Id != "" { + links = append(links, Link{Name: cve.Id, URL: "https://nvd.nist.gov/vuln/detail/" + cve.Id}) + } + } + return VulnerabilityReport{ + ID: id, + Name: name, + Description: desc, + Severity: severity, + Solution: solution, + Identifiers: identifiers, + Location: location, + Links: links, + } +} + +func deterministicVulnID(pkg, version, issueId string, cves []formats.CveRow) string { + h := sha256.New() + h.Write([]byte(pkg)) + h.Write([]byte("|")) + h.Write([]byte(version)) + h.Write([]byte("|")) + h.Write([]byte(issueId)) + for _, c := range cves { + h.Write([]byte(c.Id)) + } + sum := h.Sum(nil) + hexStr := hex.EncodeToString(sum) + // Format as UUID-like 8-4-4-4-12 for compatibility + if len(hexStr) < 32 { + hexStr = hexStr + strings.Repeat("0", 32-len(hexStr)) + } + return hexStr[0:8] + "-" + hexStr[8:12] + "-" + hexStr[12:16] + "-" + hexStr[16:20] + "-" + hexStr[20:32] +} + +func buildIdentifiers(v *formats.VulnerabilityOrViolationRow) []Identifier { + var ids []Identifier + for _, cve := range v.Cves { + if cve.Id != "" { + ids = append(ids, Identifier{ + Type: "cve", + Name: "CVE", + Value: cve.Id, + URL: "https://nvd.nist.gov/vuln/detail/" + cve.Id, + }) + } + } + if v.IssueId != "" && !strings.HasPrefix(strings.ToUpper(v.IssueId), "CVE-") { + ids = append(ids, Identifier{ + Type: "xray", + Name: "Xray", + Value: v.IssueId, + }) + } + if len(ids) == 0 { + ids = append(ids, Identifier{ + Type: "other", + Name: "JFrog Xray", + Value: v.ImpactedDependencyName + "@" + v.ImpactedDependencyVersion, + }) + } + return ids +} + +func getSeverity(v *formats.VulnerabilityOrViolationRow) string { + if v.Severity != "" { + return v.Severity + } + if v.ImpactedDependencyDetails.SeverityDetails.Severity != "" { + return v.ImpactedDependencyDetails.SeverityDetails.Severity + } + return "" +} + +func getSummary(v *formats.VulnerabilityOrViolationRow) string { + if v.Summary != "" { + return v.Summary + } + if v.JfrogResearchInformation != nil && v.JfrogResearchInformation.Summary != "" { + return v.JfrogResearchInformation.Summary + } + return "" +} + +func normalizeSeverity(severity string) string { + switch strings.ToLower(severity) { + case "critical": + return "Critical" + case "high": + return "High" + case "medium", "moderate": + return "Medium" + case "low": + return "Low" + case "info", "informational": + return "Info" + default: + return "Unknown" + } +} + +func manifestFileForTechnology(tech techutils.Technology) string { + switch tech { + case techutils.Npm, techutils.Yarn: + return "package-lock.json" + case techutils.Go: + return "go.sum" + case techutils.Pip, techutils.Pipenv: + return "requirements.txt" + case techutils.Maven: + return "pom.xml" + case techutils.Nuget: + return "packages.config" + default: + return "manifest" + } +} + +// WriteDependencyScanningReport writes the GitLab dependency-scanning report to outputDir/gl-dependency-scanning-report.json. +func WriteDependencyScanningReport(outputDir string, report *DependencyScanningReport) error { + if outputDir == "" { + return fmt.Errorf("output directory is required") + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + path := filepath.Join(outputDir, "gl-dependency-scanning-report.json") + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("marshal report: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write report: %w", err) + } + log.Info(fmt.Sprintf("GitLab dependency-scanning report written to %s", path)) + return nil +} diff --git a/utils/utils.go b/utils/utils.go index 1876d2c6b..f573a3804 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,11 +8,14 @@ import ( "fmt" "net/http" "os" + "path/filepath" "regexp" "sort" "strings" "sync" + "time" + "github.com/CycloneDX/cyclonedx-go" "github.com/jfrog/froggit-go/vcsclient" "github.com/jfrog/gofrog/version" "github.com/jfrog/jfrog-cli-core/v2/common/commands" @@ -29,6 +32,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/jfrog/frogbot/v2/utils/gitlabreport" "github.com/jfrog/frogbot/v2/utils/issues" ) @@ -49,7 +53,8 @@ const ( skipIndirectVulnerabilitiesMsg = "\n%s is an indirect dependency that will not be updated to version %s.\nFixing indirect dependencies can potentially cause conflicts with other dependencies that depend on the previous version.\nFrogbot skips this to avoid potential incompatibilities and breaking changes." skipBuildToolDependencyMsg = "Skipping vulnerable package %s since it is not defined in your package descriptor file. " + "Update %s version to %s to fix this vulnerability." - JfrogHomeDirEnv = "JFROG_CLI_HOME_DIR" + JfrogHomeDirEnv = "JFROG_CLI_HOME_DIR" + cyclonedxOutputFilename = "cyclonedx.json" ) var ( @@ -459,3 +464,53 @@ func CreateErrorIfFailUponScannerErrorEnabled(fail bool, messageForLog string, e } return err } + +func WriteScanResultsToDir(outputDir string, scanResults *results.SecurityCommandResults, startTime time.Time) error { + if outputDir == "" { + return fmt.Errorf("output directory is required") + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + endTime := time.Now().UTC() + + if err := writeCycloneDxToDir(outputDir, scanResults); err != nil { + return fmt.Errorf("write CycloneDX: %w", err) + } + report, err := gitlabreport.ConvertToGitLabDependencyScanningReport(scanResults, startTime, endTime, FrogbotVersion) + if err != nil { + return fmt.Errorf("convert to GitLab report: %w", err) + } + if err = gitlabreport.WriteDependencyScanningReport(outputDir, report); err != nil { + return fmt.Errorf("write GitLab report: %w", err) + } + log.Info(fmt.Sprintf("Scan results written to %s (CycloneDX and GitLab dependency-scanning format)", outputDir)) + return nil +} + +func writeCycloneDxToDir(outputDir string, scanResults *results.SecurityCommandResults) error { + if scanResults == nil { + return fmt.Errorf("scan results are required") + } + fullBom, err := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ + HasViolationContext: scanResults.HasViolationContext(), + IncludeVulnerabilities: scanResults.IncludesVulnerabilities(), + IncludeSbom: true, + }).ConvertToCycloneDx(scanResults) + if err != nil { + return fmt.Errorf("convert to CycloneDX: %w", err) + } + bom := fullBom.BOM + path := filepath.Join(outputDir, cyclonedxOutputFilename) + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer func() { _ = f.Close() }() + encoder := cyclonedx.NewBOMEncoder(f, cyclonedx.BOMFileFormatJSON) + if err = encoder.Encode(&bom); err != nil { + return fmt.Errorf("encode CycloneDX: %w", err) + } + log.Info(fmt.Sprintf("CycloneDX SBOM written to %s", path)) + return nil +} From 55be03542f42d199de81effb55981a5888e61010 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Sun, 12 Apr 2026 10:02:25 +0300 Subject: [PATCH 2/4] with tests --- utils/gitlabreport/gitlabreport_test.go | 256 ++++++++++++++++++++++++ utils/utils_test.go | 77 ++++++- 2 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 utils/gitlabreport/gitlabreport_test.go diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go new file mode 100644 index 000000000..d0eea9880 --- /dev/null +++ b/utils/gitlabreport/gitlabreport_test.go @@ -0,0 +1,256 @@ +package gitlabreport + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/results" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeSeverity(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"critical", "Critical"}, + {"CRITICAL", "Critical"}, + {"high", "High"}, + {"medium", "Medium"}, + {"moderate", "Medium"}, + {"low", "Low"}, + {"info", "Info"}, + {"informational", "Info"}, + {"", "Unknown"}, + {"weird", "Unknown"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, normalizeSeverity(tt.input)) + }) + } +} + +func TestManifestFileForTechnology(t *testing.T) { + tests := []struct { + tech techutils.Technology + expected string + }{ + {techutils.Npm, "package-lock.json"}, + {techutils.Yarn, "package-lock.json"}, + {techutils.Go, "go.sum"}, + {techutils.Pip, "requirements.txt"}, + {techutils.Pipenv, "requirements.txt"}, + {techutils.Maven, "pom.xml"}, + {techutils.Nuget, "packages.config"}, + {techutils.Technology("unknown"), "manifest"}, + } + for _, tt := range tests { + t.Run(string(tt.tech), func(t *testing.T) { + assert.Equal(t, tt.expected, manifestFileForTechnology(tt.tech)) + }) + } +} + +func TestFormatGitLabTime(t *testing.T) { + loc := time.FixedZone("CST", -6*3600) + ts := time.Date(2024, 6, 1, 12, 30, 45, 0, loc) + assert.Equal(t, "2024-06-01T18:30:45", formatGitLabTime(ts)) +} + +func TestDeterministicVulnID(t *testing.T) { + id1 := deterministicVulnID("pkg", "1.0.0", "XRAY-1", []formats.CveRow{{Id: "CVE-2024-1"}}) + id2 := deterministicVulnID("pkg", "1.0.0", "XRAY-1", []formats.CveRow{{Id: "CVE-2024-1"}}) + id3 := deterministicVulnID("pkg", "1.0.0", "XRAY-1", []formats.CveRow{{Id: "CVE-2024-2"}}) + assert.Equal(t, id1, id2) + assert.NotEqual(t, id1, id3) + assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, id1) +} + +func TestMakeAnalyzerScanner(t *testing.T) { + tests := []struct { + version string + wantVer string + }{ + {"1.2.3", "1.2.3"}, + {"", "0.0.0"}, + } + for _, tt := range tests { + t.Run(tt.wantVer, func(t *testing.T) { + got := makeAnalyzerScanner(tt.version) + assert.Equal(t, frogbotAnalyzerID, got.ID) + assert.Equal(t, frogbotAnalyzerName, got.Name) + assert.Equal(t, tt.wantVer, got.Version) + assert.Equal(t, frogbotVendorName, got.Vendor.Name) + }) + } +} + +func TestVulnerabilityToReport(t *testing.T) { + tests := []struct { + name string + row formats.VulnerabilityOrViolationRow + // spot checks + wantName string + wantSeverity string + wantManifest string + wantSolution string + identifierTypes []string + }{ + { + name: "CVE name and link", + row: formats.VulnerabilityOrViolationRow{ + IssueId: "XRAY-99", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "lodash", + ImpactedDependencyVersion: "4.17.20", + SeverityDetails: formats.SeverityDetails{Severity: "high"}, + }, + Cves: []formats.CveRow{{Id: "CVE-2021-1234"}}, + Summary: "Test summary", + Technology: techutils.Npm, + FixedVersions: []string{"4.17.21"}, + }, + wantName: "CVE-2021-1234", + wantSeverity: "High", + wantManifest: "package-lock.json", + wantSolution: "Upgrade lodash to version 4.17.21 or later.", + identifierTypes: []string{"cve", "xray"}, + }, + { + name: "non-CVE issue id adds xray identifier", + row: formats.VulnerabilityOrViolationRow{ + IssueId: "XRAY-100", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "foo", + ImpactedDependencyVersion: "1.0.0", + SeverityDetails: formats.SeverityDetails{Severity: "low"}, + }, + Technology: techutils.Go, + }, + wantName: "XRAY-100", + wantSeverity: "Low", + wantManifest: "go.sum", + identifierTypes: []string{"xray"}, + }, + { + name: "fallback identifier when no CVE or issue id", + row: formats.VulnerabilityOrViolationRow{ + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "orphan", + ImpactedDependencyVersion: "0.0.1", + }, + Technology: techutils.Maven, + }, + wantName: "", + wantSeverity: "Unknown", + wantManifest: "pom.xml", + identifierTypes: []string{"other"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := vulnerabilityToReport(&tt.row) + assert.Equal(t, tt.wantName, got.Name) + assert.Equal(t, tt.wantSeverity, got.Severity) + assert.Equal(t, tt.wantManifest, got.Location.File) + if tt.wantSolution != "" { + assert.Equal(t, tt.wantSolution, got.Solution) + } + require.Len(t, got.Identifiers, len(tt.identifierTypes)) + for i, wantType := range tt.identifierTypes { + assert.Equal(t, wantType, got.Identifiers[i].Type) + } + }) + } +} + +func scanResultsWithSbomOnly() *results.SecurityCommandResults { + components := []cyclonedx.Component{ + {BOMRef: "c1", Type: cyclonedx.ComponentTypeLibrary, Name: "express", Version: "4.18.2"}, + } + bom := cyclonedx.NewBOM() + bom.Components = &components + return &results.SecurityCommandResults{ + ResultsMetaData: results.ResultsMetaData{StartTime: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}, + Targets: []*results.TargetResults{{ + ScanTarget: results.ScanTarget{Target: "t1"}, + ScaResults: &results.ScaScanResults{Sbom: bom}, + }}, + } +} + +func TestConvertToGitLabDependencyScanningReport(t *testing.T) { + start := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + end := time.Date(2024, 1, 15, 10, 35, 0, 0, time.UTC) + version := "9.9.9" + + t.Run("nil scan results", func(t *testing.T) { + report, err := ConvertToGitLabDependencyScanningReport(nil, start, end, version) + require.NoError(t, err) + require.NotNil(t, report) + assert.Equal(t, "success", report.Scan.Status) + assert.Empty(t, report.Vulnerabilities) + assert.Equal(t, gitLabReportSchemaVersion, report.Version) + assert.Equal(t, gitLabReportSchemaURL, report.Schema) + assert.Equal(t, "dependency_scanning", report.Scan.Type) + assert.Equal(t, formatGitLabTime(start), report.Scan.StartTime) + assert.Equal(t, formatGitLabTime(end), report.Scan.EndTime) + assert.Equal(t, version, report.Scan.Analyzer.Version) + }) + + t.Run("scan with SBOM only", func(t *testing.T) { + report, err := ConvertToGitLabDependencyScanningReport(scanResultsWithSbomOnly(), start, end, version) + require.NoError(t, err) + assert.Equal(t, "success", report.Scan.Status) + }) + + t.Run("failure status when GetErrors returns error", func(t *testing.T) { + sr := scanResultsWithSbomOnly() + sr.GeneralError = errors.New("scanner failed") + report, err := ConvertToGitLabDependencyScanningReport(sr, start, end, version) + require.NoError(t, err) + assert.Equal(t, "failure", report.Scan.Status) + }) +} + +func TestWriteDependencyScanningReport(t *testing.T) { + t.Run("empty output dir", func(t *testing.T) { + err := WriteDependencyScanningReport("", &DependencyScanningReport{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "output directory is required") + }) + + t.Run("writes JSON file", func(t *testing.T) { + dir := t.TempDir() + report := &DependencyScanningReport{ + Version: gitLabReportSchemaVersion, + Schema: gitLabReportSchemaURL, + Scan: ScanReport{ + Status: "success", + Type: "dependency_scanning", + StartTime: "2024-01-01T00:00:00", + EndTime: "2024-01-01T00:01:00", + Analyzer: makeAnalyzerScanner("1.0.0"), + Scanner: makeAnalyzerScanner("1.0.0"), + }, + Vulnerabilities: []VulnerabilityReport{}, + } + require.NoError(t, WriteDependencyScanningReport(dir, report)) + path := filepath.Join(dir, "gl-dependency-scanning-report.json") + data, err := os.ReadFile(path) + require.NoError(t, err) + var decoded DependencyScanningReport + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, gitLabReportSchemaVersion, decoded.Version) + assert.Equal(t, "success", decoded.Scan.Status) + }) +} diff --git a/utils/utils_test.go b/utils/utils_test.go index 961f87d68..d83551b4c 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -1,6 +1,7 @@ package utils import ( + "encoding/json" "net/http/httptest" "os" "path" @@ -9,7 +10,6 @@ import ( "time" "github.com/CycloneDX/cyclonedx-go" - "github.com/jfrog/frogbot/v2/utils/outputwriter" "github.com/jfrog/froggit-go/vcsclient" "github.com/jfrog/froggit-go/vcsutils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" @@ -18,6 +18,9 @@ import ( "github.com/jfrog/jfrog-cli-security/utils/results" "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/stretchr/testify/assert" + + "github.com/jfrog/frogbot/v2/utils/gitlabreport" + "github.com/jfrog/frogbot/v2/utils/outputwriter" ) const ( @@ -560,3 +563,75 @@ func createTestSecurityCommandResults() *results.SecurityCommandResults { return scanResults } + +func TestWriteScanResultsToDir(t *testing.T) { + start := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + tests := []struct { + name string + outputDir func(t *testing.T) string + scanResults *results.SecurityCommandResults + wantErr bool + errContains string + validate func(t *testing.T, outputDir string) + }{ + { + name: "empty output dir", + outputDir: func(t *testing.T) string { + return "" + }, + scanResults: createTestSecurityCommandResults(), + wantErr: true, + errContains: "output directory is required", + }, + { + name: "nil scan results", + outputDir: func(t *testing.T) string { + return t.TempDir() + }, + scanResults: nil, + wantErr: true, + errContains: "scan results are required", + }, + { + name: "writes CycloneDX and GitLab dependency-scanning report", + outputDir: func(t *testing.T) string { + return t.TempDir() + }, + scanResults: createTestSecurityCommandResults(), + wantErr: false, + validate: func(t *testing.T, outputDir string) { + cdxPath := filepath.Join(outputDir, cyclonedxOutputFilename) + _, err := os.Stat(cdxPath) + assert.NoError(t, err) + + gitlabPath := filepath.Join(outputDir, "gl-dependency-scanning-report.json") + data, err := os.ReadFile(gitlabPath) + assert.NoError(t, err) + + var report gitlabreport.DependencyScanningReport + assert.NoError(t, json.Unmarshal(data, &report)) + assert.Equal(t, "15.2.4", report.Version) + assert.Equal(t, "success", report.Scan.Status) + assert.Equal(t, "dependency_scanning", report.Scan.Type) + assert.Equal(t, FrogbotVersion, report.Scan.Analyzer.Version) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.outputDir(t) + err := WriteScanResultsToDir(dir, tt.scanResults, start) + if tt.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, dir) + } + }) + } +} From dacd00423e9753e16f4211467d8d6a0d23c5eaf7 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Sun, 12 Apr 2026 10:12:47 +0300 Subject: [PATCH 3/4] static analysis fix --- scanrepository/scanrepository.go | 7 +++---- utils/gitlabreport/gitlabreport.go | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scanrepository/scanrepository.go b/scanrepository/scanrepository.go index 88bc5e68a..00d9db25c 100644 --- a/scanrepository/scanrepository.go +++ b/scanrepository/scanrepository.go @@ -157,14 +157,13 @@ func (sr *ScanRepositoryCmd) scanAndFixBranch(repository *utils.Repository) (tot if repository.Params.Git.GitProvider == vcsutils.GitLab && repository.Params.Git.GitlabScanResultsOutputDir != "" { log.Debug(fmt.Sprintf("Trying to save scan results to directory: %s", repository.Params.Git.GitlabScanResultsOutputDir)) - if err = utils.WriteScanResultsToDir(repository.Params.Git.GitlabScanResultsOutputDir, scanResults, sr.scanDetails.StartTime); err != nil { - log.Warn(fmt.Sprintf("Failed to write scan results to directory: %s", err.Error())) + if writeErr := utils.WriteScanResultsToDir(repository.Params.Git.GitlabScanResultsOutputDir, scanResults, sr.scanDetails.StartTime); writeErr != nil { + log.Warn(fmt.Sprintf("Failed to write scan results to directory: %s", writeErr.Error())) } - return } if !repository.Params.FrogbotConfig.CreateAutoFixPr { - log.Info(fmt.Sprintf("This command is running in detection mode only. To enable automatic fixing of issues, set the '%s' flag under the repository's coniguration settings in Jfrog platform", createAutoFixPrConfigNameInProfile)) + log.Info(fmt.Sprintf("This command is running in detection mode only. To enable automatic fixing of issues, set the '%s' flag under the repository's configuration settings in Jfrog platform", createAutoFixPrConfigNameInProfile)) return totalFindings, nil } diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 0667e0201..d6f13a797 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -225,7 +225,7 @@ func deterministicVulnID(pkg, version, issueId string, cves []formats.CveRow) st hexStr := hex.EncodeToString(sum) // Format as UUID-like 8-4-4-4-12 for compatibility if len(hexStr) < 32 { - hexStr = hexStr + strings.Repeat("0", 32-len(hexStr)) + hexStr += strings.Repeat("0", 32-len(hexStr)) } return hexStr[0:8] + "-" + hexStr[8:12] + "-" + hexStr[12:16] + "-" + hexStr[16:20] + "-" + hexStr[20:32] } From c31890fe230ca625000c43c16d4b1dd89586fca8 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 13 Apr 2026 14:10:26 +0300 Subject: [PATCH 4/4] with applicability status --- utils/gitlabreport/gitlabreport.go | 149 ++++++++++++++++++++++-- utils/gitlabreport/gitlabreport_test.go | 65 ++++++++++- 2 files changed, 203 insertions(+), 11 deletions(-) diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index d6f13a797..36fc1932a 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -7,10 +7,12 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/jfrog/jfrog-cli-security/utils/results" "github.com/jfrog/jfrog-cli-security/utils/results/conversion" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -121,18 +123,22 @@ func ConvertToGitLabDependencyScanningReport(scanResults *results.SecurityComman vulns = append(vulns, simpleJSON.Vulnerabilities...) vulns = append(vulns, simpleJSON.SecurityViolations...) - reports := make([]VulnerabilityReport, 0, len(vulns)) + unique := make([]formats.VulnerabilityOrViolationRow, 0, len(vulns)) seen := make(map[string]struct{}) - for i := range vulns { - v := &vulns[i] + v := vulns[i] key := v.ImpactedDependencyName + "|" + v.ImpactedDependencyVersion + "|" + v.IssueId if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} + unique = append(unique, v) + } + sortVulnerabilityRowsForGitLab(unique) - report := vulnerabilityToReport(v) + reports := make([]VulnerabilityReport, 0, len(unique)) + for i := range unique { + report := vulnerabilityToReport(&unique[i]) reports = append(reports, report) } @@ -184,11 +190,11 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability }, } severity := normalizeSeverity(getSeverity(v)) - name := v.IssueId - if len(v.Cves) > 0 { - name = v.Cves[0].Id - } - desc := getSummary(v) + // GitLab's vulnerability list "Description" column is built from the finding title (name) and + // manifest path — it does not show the JSON description body in that column. Include + // contextual analysis in name so it appears in the list; description still holds full text. + name := buildVulnerabilityNameWithContextualAnalysis(v) + desc := buildGitLabDescription(v) solution := "" if len(v.FixedVersions) > 0 { solution = fmt.Sprintf("Upgrade %s to version %s or later.", v.ImpactedDependencyName, v.FixedVersions[0]) @@ -279,6 +285,131 @@ func getSummary(v *formats.VulnerabilityOrViolationRow) string { return "" } +// buildVulnerabilityNameWithContextualAnalysis sets the GitLab finding title to "CVE-ID (status)" using +// aggregated contextual analysis for the row (same aggregation as Frogbot PR comments). +func buildVulnerabilityNameWithContextualAnalysis(v *formats.VulnerabilityOrViolationRow) string { + base := v.IssueId + if len(v.Cves) > 0 && v.Cves[0].Id != "" { + base = v.Cves[0].Id + } + if base == "" { + return "" + } + return fmt.Sprintf("%s (%s)", base, aggregatedContextualAnalysisDisplay(v)) +} + +// aggregatedContextualAnalysisDisplay returns a human-readable status; NotScanned maps to "Not Covered". +func aggregatedContextualAnalysisDisplay(v *formats.VulnerabilityOrViolationRow) string { + st := rowFinalApplicabilityStatus(v) + if st == jasutils.NotScanned || st.String() == "" { + return jasutils.NotCovered.String() + } + return st.String() +} + +// buildGitLabDescription prepends contextual analysis lines in the form "CVE-ID (status)." for each CVE, +// then the vulnerability summary (when present). +func buildGitLabDescription(v *formats.VulnerabilityOrViolationRow) string { + prefix := contextualAnalysisDescriptionPrefix(v) + summary := getSummary(v) + switch { + case prefix != "" && summary != "": + return prefix + "\n\n" + summary + case prefix != "": + return prefix + default: + return summary + } +} + +// contextualAnalysisDescriptionPrefix builds "CVE-2024-1 (Applicable). CVE-2024-2 (Not Applicable)." per CVE row. +// When a CVE has no applicability assessment, status is "Not Covered". +func contextualAnalysisDescriptionPrefix(v *formats.VulnerabilityOrViolationRow) string { + var b strings.Builder + for _, cve := range v.Cves { + if cve.Id == "" { + continue + } + status := jasutils.NotCovered.String() + if cve.Applicability != nil && cve.Applicability.Status != "" { + status = cve.Applicability.Status + } + if b.Len() > 0 { + b.WriteString(" ") + } + b.WriteString(cve.Id) + b.WriteString(" (") + b.WriteString(status) + b.WriteString(").") + } + return b.String() +} + +func sortVulnerabilityRowsForGitLab(vulns []formats.VulnerabilityOrViolationRow) { + sort.SliceStable(vulns, func(i, j int) bool { + si := normalizeSeverity(getSeverity(&vulns[i])) + sj := normalizeSeverity(getSeverity(&vulns[j])) + ri, rj := severitySortRank(si), severitySortRank(sj) + if ri != rj { + return ri < rj + } + ai := applicabilitySortRank(rowFinalApplicabilityStatus(&vulns[i])) + aj := applicabilitySortRank(rowFinalApplicabilityStatus(&vulns[j])) + if ai != aj { + return ai < aj + } + return vulns[i].IssueId < vulns[j].IssueId + }) +} + +func severitySortRank(normalized string) int { + switch normalized { + case "Critical": + return 0 + case "High": + return 1 + case "Medium": + return 2 + case "Low": + return 3 + case "Info": + return 4 + default: + return 5 // Unknown + } +} + +// rowFinalApplicabilityStatus aggregates per-CVE applicability like Frogbot PR comments. +func rowFinalApplicabilityStatus(v *formats.VulnerabilityOrViolationRow) jasutils.ApplicabilityStatus { + var statuses []jasutils.ApplicabilityStatus + for _, cve := range v.Cves { + if cve.Applicability != nil && cve.Applicability.Status != "" { + statuses = append(statuses, jasutils.ConvertToApplicabilityStatus(cve.Applicability.Status)) + } + } + return results.GetFinalApplicabilityStatus(len(statuses) > 0, statuses) +} + +// applicabilitySortRank orders rows within the same severity: Applicable first, Not Applicable last. +func applicabilitySortRank(status jasutils.ApplicabilityStatus) int { + switch status { + case jasutils.Applicable: + return 0 + case jasutils.ApplicabilityUndetermined: + return 1 + case jasutils.MissingContext: + return 2 + case jasutils.NotCovered: + return 3 + case jasutils.NotScanned: + return 4 + case jasutils.NotApplicable: + return 5 + default: + return 6 + } +} + func normalizeSeverity(severity string) string { switch strings.ToLower(severity) { case "critical": diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index d0eea9880..e2e4d0792 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -10,6 +10,7 @@ import ( "github.com/CycloneDX/cyclonedx-go" "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/jfrog/jfrog-cli-security/utils/results" "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/stretchr/testify/assert" @@ -119,12 +120,33 @@ func TestVulnerabilityToReport(t *testing.T) { Technology: techutils.Npm, FixedVersions: []string{"4.17.21"}, }, - wantName: "CVE-2021-1234", + wantName: "CVE-2021-1234 (Not Covered)", wantSeverity: "High", wantManifest: "package-lock.json", wantSolution: "Upgrade lodash to version 4.17.21 or later.", identifierTypes: []string{"cve", "xray"}, }, + { + name: "contextual analysis in description", + row: formats.VulnerabilityOrViolationRow{ + IssueId: "XRAY-99", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "pkg", + ImpactedDependencyVersion: "1.0.0", + SeverityDetails: formats.SeverityDetails{Severity: "low"}, + }, + Cves: []formats.CveRow{ + {Id: "CVE-2023-1", Applicability: &formats.Applicability{Status: jasutils.Applicable.String()}}, + {Id: "CVE-2023-2", Applicability: &formats.Applicability{Status: jasutils.NotApplicable.String()}}, + }, + Summary: "Details here", + Technology: techutils.Npm, + }, + wantName: "CVE-2023-1 (Applicable)", + wantSeverity: "Low", + wantManifest: "package-lock.json", + identifierTypes: []string{"cve", "cve", "xray"}, + }, { name: "non-CVE issue id adds xray identifier", row: formats.VulnerabilityOrViolationRow{ @@ -136,7 +158,7 @@ func TestVulnerabilityToReport(t *testing.T) { }, Technology: techutils.Go, }, - wantName: "XRAY-100", + wantName: "XRAY-100 (Not Covered)", wantSeverity: "Low", wantManifest: "go.sum", identifierTypes: []string{"xray"}, @@ -162,6 +184,12 @@ func TestVulnerabilityToReport(t *testing.T) { assert.Equal(t, tt.wantName, got.Name) assert.Equal(t, tt.wantSeverity, got.Severity) assert.Equal(t, tt.wantManifest, got.Location.File) + if tt.name == "CVE name and link" { + assert.Equal(t, "CVE-2021-1234 (Not Covered).\n\nTest summary", got.Description) + } + if tt.name == "contextual analysis in description" { + assert.Equal(t, "CVE-2023-1 (Applicable). CVE-2023-2 (Not Applicable).\n\nDetails here", got.Description) + } if tt.wantSolution != "" { assert.Equal(t, tt.wantSolution, got.Solution) } @@ -222,6 +250,39 @@ func TestConvertToGitLabDependencyScanningReport(t *testing.T) { }) } +func TestSortVulnerabilityRowsForGitLab(t *testing.T) { + rows := []formats.VulnerabilityOrViolationRow{ + { + IssueId: "b-low-na", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "p1", ImpactedDependencyVersion: "1", + SeverityDetails: formats.SeverityDetails{Severity: "low"}, + }, + Cves: []formats.CveRow{{Id: "CVE-B", Applicability: &formats.Applicability{Status: jasutils.NotApplicable.String()}}}, + }, + { + IssueId: "a-high-app", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "p2", ImpactedDependencyVersion: "1", + SeverityDetails: formats.SeverityDetails{Severity: "high"}, + }, + Cves: []formats.CveRow{{Id: "CVE-A", Applicability: &formats.Applicability{Status: jasutils.Applicable.String()}}}, + }, + { + IssueId: "c-low-app", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "p3", ImpactedDependencyVersion: "1", + SeverityDetails: formats.SeverityDetails{Severity: "low"}, + }, + Cves: []formats.CveRow{{Id: "CVE-C", Applicability: &formats.Applicability{Status: jasutils.Applicable.String()}}}, + }, + } + sortVulnerabilityRowsForGitLab(rows) + assert.Equal(t, "a-high-app", rows[0].IssueId) + assert.Equal(t, "c-low-app", rows[1].IssueId) + assert.Equal(t, "b-low-na", rows[2].IssueId) +} + func TestWriteDependencyScanningReport(t *testing.T) { t.Run("empty output dir", func(t *testing.T) { err := WriteDependencyScanningReport("", &DependencyScanningReport{})