From 5376364549273497b09c622f77962c8aa7732822 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Mon, 30 Mar 2026 11:57:23 +0300 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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{}) From eb3be974d697d3ab41beb5a8da22b08024ff8cfa Mon Sep 17 00:00:00 2001 From: Or Toren Date: Tue, 28 Apr 2026 11:52:20 +0300 Subject: [PATCH 05/17] update Identifier column with cve id --- utils/gitlabreport/gitlabreport.go | 18 +++++++++--- utils/gitlabreport/gitlabreport_test.go | 39 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 36fc1932a..aea24b853 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -242,7 +242,7 @@ func buildIdentifiers(v *formats.VulnerabilityOrViolationRow) []Identifier { if cve.Id != "" { ids = append(ids, Identifier{ Type: "cve", - Name: "CVE", + Name: cve.Id, Value: cve.Id, URL: "https://nvd.nist.gov/vuln/detail/" + cve.Id, }) @@ -251,15 +251,25 @@ func buildIdentifiers(v *formats.VulnerabilityOrViolationRow) []Identifier { if v.IssueId != "" && !strings.HasPrefix(strings.ToUpper(v.IssueId), "CVE-") { ids = append(ids, Identifier{ Type: "xray", - Name: "Xray", + Name: v.IssueId, Value: v.IssueId, }) } if len(ids) == 0 { + issue := strings.TrimSpace(v.IssueId) + if issue != "" && strings.HasPrefix(strings.ToUpper(issue), "CVE-") { + return []Identifier{{ + Type: "cve", + Name: issue, + Value: issue, + URL: "https://nvd.nist.gov/vuln/detail/" + issue, + }} + } + fallback := v.ImpactedDependencyName + "@" + v.ImpactedDependencyVersion ids = append(ids, Identifier{ Type: "other", - Name: "JFrog Xray", - Value: v.ImpactedDependencyName + "@" + v.ImpactedDependencyVersion, + Name: fallback, + Value: fallback, }) } return ids diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index e2e4d0792..ad285d7ec 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -177,6 +177,24 @@ func TestVulnerabilityToReport(t *testing.T) { wantManifest: "pom.xml", identifierTypes: []string{"other"}, }, + { + name: "CVE from issueId when cves slice empty", + row: formats.VulnerabilityOrViolationRow{ + IssueId: "CVE-2020-9999", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "dep", + ImpactedDependencyVersion: "1.0.0", + SeverityDetails: formats.SeverityDetails{Severity: "high"}, + }, + Cves: nil, + Summary: "from issue id only", + Technology: techutils.Maven, + }, + wantName: "CVE-2020-9999 (Not Covered)", + wantSeverity: "High", + wantManifest: "pom.xml", + identifierTypes: []string{"cve"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -196,11 +214,32 @@ func TestVulnerabilityToReport(t *testing.T) { require.Len(t, got.Identifiers, len(tt.identifierTypes)) for i, wantType := range tt.identifierTypes { assert.Equal(t, wantType, got.Identifiers[i].Type) + assert.NotEmpty(t, got.Identifiers[i].Value, "identifier value must be set for GitLab vulnerability report") + assert.Equal(t, got.Identifiers[i].Value, got.Identifiers[i].Name, "identifier name should match value for GitLab UI") } }) } } +func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { + row := formats.VulnerabilityOrViolationRow{ + IssueId: "XRAY-1", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "lib", + ImpactedDependencyVersion: "1.0.0", + SeverityDetails: formats.SeverityDetails{Severity: "high"}, + }, + Cves: []formats.CveRow{{Id: "CVE-2021-9999"}}, + Technology: techutils.Npm, + } + rep := vulnerabilityToReport(&row) + raw, err := json.Marshal(rep) + require.NoError(t, err) + assert.Contains(t, string(raw), `"type":"cve"`) + assert.Contains(t, string(raw), `"name":"CVE-2021-9999"`) + assert.Contains(t, string(raw), `"value":"CVE-2021-9999"`) +} + func scanResultsWithSbomOnly() *results.SecurityCommandResults { components := []cyclonedx.Component{ {BOMRef: "c1", Type: cyclonedx.ComponentTypeLibrary, Name: "express", Version: "4.18.2"}, From f8ebc0fa1673592402bebc975e60649f569db2a0 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Tue, 28 Apr 2026 12:37:11 +0300 Subject: [PATCH 06/17] reachable column --- utils/gitlabreport/gitlabreport.go | 75 +++++++++++++++++-------- utils/gitlabreport/gitlabreport_test.go | 34 +++++++++-- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index aea24b853..7c108f14a 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -56,14 +56,28 @@ type Vendor struct { } 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"` + 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"` + Details *DetailsNamedList `json:"details,omitempty"` // e.g. Reachable (contextual analysis); see dependency-scanning schema `details` +} + +// DetailsNamedList is GitLab's named-list detail block (security report schema). +type DetailsNamedList struct { + Type string `json:"type"` // must be "named-list" + Items map[string]DetailNamedListItem `json:"items"` +} + +// DetailNamedListItem merges named_field (name) with a detail payload (e.g. type "text" + value). +type DetailNamedListItem struct { + Name string `json:"name"` + Type string `json:"type"` // "text" + Value string `json:"value"` } type Identifier struct { @@ -192,9 +206,14 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability severity := normalizeSeverity(getSeverity(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. + // contextual analysis in name so it appears in the list; description holds summary only. name := buildVulnerabilityNameWithContextualAnalysis(v) - desc := buildGitLabDescription(v) + desc := strings.TrimSpace(getSummary(v)) + reach := contextualAnalysisReachabilityText(v) + var details *DetailsNamedList + if strings.TrimSpace(reach) != "" { + details = buildReachabilityNamedList(reach) + } solution := "" if len(v.FixedVersions) > 0 { solution = fmt.Sprintf("Upgrade %s to version %s or later.", v.ImpactedDependencyName, v.FixedVersions[0]) @@ -214,6 +233,7 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability Identifiers: identifiers, Location: location, Links: links, + Details: details, } } @@ -317,18 +337,29 @@ func aggregatedContextualAnalysisDisplay(v *formats.VulnerabilityOrViolationRow) 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 +// contextualAnalysisReachabilityText returns contextual analysis (JAS applicability) for the +// Reachable detail field: per-CVE lines when available, otherwise the row-level status when the +// finding has a titled vulnerability. +func contextualAnalysisReachabilityText(v *formats.VulnerabilityOrViolationRow) string { + if s := contextualAnalysisDescriptionPrefix(v); strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + if buildVulnerabilityNameWithContextualAnalysis(v) == "" { + return "" + } + return aggregatedContextualAnalysisDisplay(v) +} + +func buildReachabilityNamedList(reachabilityText string) *DetailsNamedList { + return &DetailsNamedList{ + Type: "named-list", + Items: map[string]DetailNamedListItem{ + "reachable": { + Name: "Reachable", + Type: "text", + Value: reachabilityText, + }, + }, } } diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index ad285d7ec..69fd92c8e 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -127,7 +127,7 @@ func TestVulnerabilityToReport(t *testing.T) { identifierTypes: []string{"cve", "xray"}, }, { - name: "contextual analysis in description", + name: "contextual analysis in reachable detail", row: formats.VulnerabilityOrViolationRow{ IssueId: "XRAY-99", ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ @@ -203,10 +203,32 @@ func TestVulnerabilityToReport(t *testing.T) { 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) + assert.Equal(t, "Test summary", got.Description) + require.NotNil(t, got.Details) + assert.Equal(t, "named-list", got.Details.Type) + item := got.Details.Items["reachable"] + assert.Equal(t, "Reachable", item.Name) + assert.Equal(t, "text", item.Type) + assert.Equal(t, "CVE-2021-1234 (Not Covered).", item.Value) } - 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.name == "contextual analysis in reachable detail" { + assert.Equal(t, "Details here", got.Description) + require.NotNil(t, got.Details) + item := got.Details.Items["reachable"] + assert.Equal(t, "CVE-2023-1 (Applicable). CVE-2023-2 (Not Applicable).", item.Value) + } + if tt.name == "non-CVE issue id adds xray identifier" { + assert.Empty(t, got.Description) + require.NotNil(t, got.Details) + assert.Equal(t, jasutils.NotCovered.String(), got.Details.Items["reachable"].Value) + } + if tt.name == "CVE from issueId when cves slice empty" { + assert.Equal(t, "from issue id only", got.Description) + require.NotNil(t, got.Details) + assert.Equal(t, jasutils.NotCovered.String(), got.Details.Items["reachable"].Value) + } + if tt.name == "fallback identifier when no CVE or issue id" { + assert.Nil(t, got.Details) } if tt.wantSolution != "" { assert.Equal(t, tt.wantSolution, got.Solution) @@ -238,6 +260,10 @@ func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { assert.Contains(t, string(raw), `"type":"cve"`) assert.Contains(t, string(raw), `"name":"CVE-2021-9999"`) assert.Contains(t, string(raw), `"value":"CVE-2021-9999"`) + assert.Contains(t, string(raw), `"details":`) + assert.Contains(t, string(raw), `"type":"named-list"`) + assert.Contains(t, string(raw), `"name":"Reachable"`) + assert.Contains(t, string(raw), `"value":"CVE-2021-9999 (Not Covered)."`) } func scanResultsWithSbomOnly() *results.SecurityCommandResults { From 957afaf3c6dc881f52713549aaa9c519b96ed590 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Tue, 28 Apr 2026 12:55:37 +0300 Subject: [PATCH 07/17] with cwe --- utils/gitlabreport/gitlabreport.go | 93 ++++++++++++++++++++++--- utils/gitlabreport/gitlabreport_test.go | 44 +++++++++--- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 7c108f14a..38a19fb8d 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -278,23 +278,100 @@ func buildIdentifiers(v *formats.VulnerabilityOrViolationRow) []Identifier { if len(ids) == 0 { issue := strings.TrimSpace(v.IssueId) if issue != "" && strings.HasPrefix(strings.ToUpper(issue), "CVE-") { - return []Identifier{{ + ids = append(ids, Identifier{ Type: "cve", Name: issue, Value: issue, URL: "https://nvd.nist.gov/vuln/detail/" + issue, - }} + }) + } else { + fallback := v.ImpactedDependencyName + "@" + v.ImpactedDependencyVersion + ids = append(ids, Identifier{ + Type: "other", + Name: fallback, + Value: fallback, + }) + } + } + return appendUniqueCWEIdentifiers(ids, v) +} + +// appendUniqueCWEIdentifiers adds GitLab dependency-scanning identifiers with type "cwe" from each +// CVE row's Cwe list (Xray simple JSON). GitLab aggregates these for dashboards such as "Top 10 CWEs". +func appendUniqueCWEIdentifiers(ids []Identifier, v *formats.VulnerabilityOrViolationRow) []Identifier { + seen := make(map[string]struct{}) + for _, id := range ids { + if id.Type == "cwe" { + seen[strings.ToUpper(id.Value)] = struct{}{} + } + } + for _, cve := range v.Cves { + for _, raw := range cve.Cwe { + canon := normalizeCweID(raw) + if canon == "" { + continue + } + key := strings.ToUpper(canon) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + id := Identifier{ + Type: "cwe", + Name: canon, + Value: canon, + } + if u := cweMitreDefinitionsURL(canon); u != "" { + id.URL = u + } + ids = append(ids, id) } - fallback := v.ImpactedDependencyName + "@" + v.ImpactedDependencyVersion - ids = append(ids, Identifier{ - Type: "other", - Name: fallback, - Value: fallback, - }) } return ids } +func normalizeCweID(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u := strings.ToUpper(raw) + if strings.HasPrefix(u, "CWE-") { + n := strings.TrimPrefix(u, "CWE-") + n = strings.TrimSpace(n) + if cweNumericID(n) != "" { + return "CWE-" + cweNumericID(n) + } + return "" + } + if cweNumericID(u) != "" { + return "CWE-" + cweNumericID(u) + } + return "" +} + +// cweNumericID returns digits-only CWE id, or empty if invalid. +func cweNumericID(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + for _, r := range s { + if r < '0' || r > '9' { + return "" + } + } + return s +} + +func cweMitreDefinitionsURL(cweCanon string) string { + n := cweNumericID(strings.TrimPrefix(strings.ToUpper(strings.TrimSpace(cweCanon)), "CWE-")) + if n == "" { + return "" + } + return "https://cwe.mitre.org/data/definitions/" + n + ".html" +} + func getSeverity(v *formats.VulnerabilityOrViolationRow) string { if v.Severity != "" { return v.Severity diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index 69fd92c8e..fe17fcfa8 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -3,6 +3,7 @@ package gitlabreport import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "testing" @@ -115,7 +116,7 @@ func TestVulnerabilityToReport(t *testing.T) { ImpactedDependencyVersion: "4.17.20", SeverityDetails: formats.SeverityDetails{Severity: "high"}, }, - Cves: []formats.CveRow{{Id: "CVE-2021-1234"}}, + Cves: []formats.CveRow{{Id: "CVE-2021-1234", Cwe: []string{"CWE-502", "502"}}}, // duplicate normalized → one CWE id Summary: "Test summary", Technology: techutils.Npm, FixedVersions: []string{"4.17.21"}, @@ -124,7 +125,7 @@ func TestVulnerabilityToReport(t *testing.T) { wantSeverity: "High", wantManifest: "package-lock.json", wantSolution: "Upgrade lodash to version 4.17.21 or later.", - identifierTypes: []string{"cve", "xray"}, + identifierTypes: []string{"cve", "xray", "cwe"}, }, { name: "contextual analysis in reachable detail", @@ -136,8 +137,8 @@ func TestVulnerabilityToReport(t *testing.T) { 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()}}, + {Id: "CVE-2023-1", Cwe: []string{"CWE-79"}, Applicability: &formats.Applicability{Status: jasutils.Applicable.String()}}, + {Id: "CVE-2023-2", Cwe: []string{"cwe-89"}, Applicability: &formats.Applicability{Status: jasutils.NotApplicable.String()}}, }, Summary: "Details here", Technology: techutils.Npm, @@ -145,7 +146,7 @@ func TestVulnerabilityToReport(t *testing.T) { wantName: "CVE-2023-1 (Applicable)", wantSeverity: "Low", wantManifest: "package-lock.json", - identifierTypes: []string{"cve", "cve", "xray"}, + identifierTypes: []string{"cve", "cve", "xray", "cwe", "cwe"}, }, { name: "non-CVE issue id adds xray identifier", @@ -156,12 +157,13 @@ func TestVulnerabilityToReport(t *testing.T) { ImpactedDependencyVersion: "1.0.0", SeverityDetails: formats.SeverityDetails{Severity: "low"}, }, + Cves: []formats.CveRow{{Cwe: []string{"20"}}}, Technology: techutils.Go, }, wantName: "XRAY-100 (Not Covered)", wantSeverity: "Low", wantManifest: "go.sum", - identifierTypes: []string{"xray"}, + identifierTypes: []string{"xray", "cwe"}, }, { name: "fallback identifier when no CVE or issue id", @@ -170,12 +172,13 @@ func TestVulnerabilityToReport(t *testing.T) { ImpactedDependencyName: "orphan", ImpactedDependencyVersion: "0.0.1", }, + Cves: []formats.CveRow{{Cwe: []string{"CWE-787"}}}, Technology: techutils.Maven, }, wantName: "", wantSeverity: "Unknown", wantManifest: "pom.xml", - identifierTypes: []string{"other"}, + identifierTypes: []string{"other", "cwe"}, }, { name: "CVE from issueId when cves slice empty", @@ -186,14 +189,14 @@ func TestVulnerabilityToReport(t *testing.T) { ImpactedDependencyVersion: "1.0.0", SeverityDetails: formats.SeverityDetails{Severity: "high"}, }, - Cves: nil, + Cves: []formats.CveRow{{Cwe: []string{"22"}}}, Summary: "from issue id only", Technology: techutils.Maven, }, wantName: "CVE-2020-9999 (Not Covered)", wantSeverity: "High", wantManifest: "pom.xml", - identifierTypes: []string{"cve"}, + identifierTypes: []string{"cve", "cwe"}, }, } for _, tt := range tests { @@ -251,7 +254,7 @@ func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { ImpactedDependencyVersion: "1.0.0", SeverityDetails: formats.SeverityDetails{Severity: "high"}, }, - Cves: []formats.CveRow{{Id: "CVE-2021-9999"}}, + Cves: []formats.CveRow{{Id: "CVE-2021-9999", Cwe: []string{"1004"}}}, Technology: techutils.Npm, } rep := vulnerabilityToReport(&row) @@ -264,6 +267,27 @@ func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { assert.Contains(t, string(raw), `"type":"named-list"`) assert.Contains(t, string(raw), `"name":"Reachable"`) assert.Contains(t, string(raw), `"value":"CVE-2021-9999 (Not Covered)."`) + assert.Contains(t, string(raw), `"type":"cwe"`) + assert.Contains(t, string(raw), `"name":"CWE-1004"`) + assert.Contains(t, string(raw), `"value":"CWE-1004"`) +} + +func TestNormalizeCweID(t *testing.T) { + tests := []struct{ in, want string }{ + {"CWE-79", "CWE-79"}, + {"cwe-89", "CWE-89"}, + {"787", "CWE-787"}, + {" 22 ", "CWE-22"}, + {"", ""}, + {"CWE-", ""}, + {"CWE-notnum", ""}, + {"nope", ""}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%q", tt.in), func(t *testing.T) { + assert.Equal(t, tt.want, normalizeCweID(tt.in)) + }) + } } func scanResultsWithSbomOnly() *results.SecurityCommandResults { From ea3cd762c7b3a3ddeed76a40fd692fdbde7b68a0 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 29 Apr 2026 11:27:30 +0300 Subject: [PATCH 08/17] fixed format issues --- utils/gitlabreport/gitlabreport.go | 48 +++++++++++-------------- utils/gitlabreport/gitlabreport_test.go | 20 +++++------ utils/utils.go | 2 +- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 38a19fb8d..05b27522d 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -56,21 +56,15 @@ type Vendor struct { } 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"` - Details *DetailsNamedList `json:"details,omitempty"` // e.g. Reachable (contextual analysis); see dependency-scanning schema `details` -} - -// DetailsNamedList is GitLab's named-list detail block (security report schema). -type DetailsNamedList struct { - Type string `json:"type"` // must be "named-list" - Items map[string]DetailNamedListItem `json:"items"` + 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"` + Details map[string]DetailNamedListItem `json:"details,omitempty"` // named-list *items* only; see schema note on buildReachabilityDetails } // DetailNamedListItem merges named_field (name) with a detail payload (e.g. type "text" + value). @@ -124,8 +118,11 @@ func ConvertToGitLabDependencyScanningReport(scanResults *results.SecurityComman }, nil } + // Always include SCA vulnerabilities in this export. scanResults.IncludesVulnerabilities() + // reflects the interactive scan context (e.g. violation-only views); when false, jfrog-cli-security + // skips ParseCVEs and the GitLab JSON would be empty even with many SBOM CVEs — wrong for CI reports. convertor := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ - IncludeVulnerabilities: scanResults.IncludesVulnerabilities(), + IncludeVulnerabilities: true, HasViolationContext: scanResults.HasViolationContext(), }) simpleJSON, err := convertor.ConvertToSimpleJson(scanResults) @@ -210,9 +207,9 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability name := buildVulnerabilityNameWithContextualAnalysis(v) desc := strings.TrimSpace(getSummary(v)) reach := contextualAnalysisReachabilityText(v) - var details *DetailsNamedList + var details map[string]DetailNamedListItem if strings.TrimSpace(reach) != "" { - details = buildReachabilityNamedList(reach) + details = buildReachabilityDetails(reach) } solution := "" if len(v.FixedVersions) > 0 { @@ -427,15 +424,12 @@ func contextualAnalysisReachabilityText(v *formats.VulnerabilityOrViolationRow) return aggregatedContextualAnalysisDisplay(v) } -func buildReachabilityNamedList(reachabilityText string) *DetailsNamedList { - return &DetailsNamedList{ - Type: "named-list", - Items: map[string]DetailNamedListItem{ - "reachable": { - Name: "Reachable", - Type: "text", - Value: reachabilityText, - }, +func buildReachabilityDetails(reachabilityText string) map[string]DetailNamedListItem { + return map[string]DetailNamedListItem{ + "reachable": { + Name: "Reachable", + Type: "text", + Value: reachabilityText, }, } } diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index fe17fcfa8..1e35a3ac1 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -207,28 +207,27 @@ func TestVulnerabilityToReport(t *testing.T) { assert.Equal(t, tt.wantManifest, got.Location.File) if tt.name == "CVE name and link" { assert.Equal(t, "Test summary", got.Description) - require.NotNil(t, got.Details) - assert.Equal(t, "named-list", got.Details.Type) - item := got.Details.Items["reachable"] + require.Contains(t, got.Details, "reachable") + item := got.Details["reachable"] assert.Equal(t, "Reachable", item.Name) assert.Equal(t, "text", item.Type) assert.Equal(t, "CVE-2021-1234 (Not Covered).", item.Value) } if tt.name == "contextual analysis in reachable detail" { assert.Equal(t, "Details here", got.Description) - require.NotNil(t, got.Details) - item := got.Details.Items["reachable"] + require.Contains(t, got.Details, "reachable") + item := got.Details["reachable"] assert.Equal(t, "CVE-2023-1 (Applicable). CVE-2023-2 (Not Applicable).", item.Value) } if tt.name == "non-CVE issue id adds xray identifier" { assert.Empty(t, got.Description) - require.NotNil(t, got.Details) - assert.Equal(t, jasutils.NotCovered.String(), got.Details.Items["reachable"].Value) + require.Contains(t, got.Details, "reachable") + assert.Equal(t, jasutils.NotCovered.String(), got.Details["reachable"].Value) } if tt.name == "CVE from issueId when cves slice empty" { assert.Equal(t, "from issue id only", got.Description) - require.NotNil(t, got.Details) - assert.Equal(t, jasutils.NotCovered.String(), got.Details.Items["reachable"].Value) + require.Contains(t, got.Details, "reachable") + assert.Equal(t, jasutils.NotCovered.String(), got.Details["reachable"].Value) } if tt.name == "fallback identifier when no CVE or issue id" { assert.Nil(t, got.Details) @@ -264,9 +263,10 @@ func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { assert.Contains(t, string(raw), `"name":"CVE-2021-9999"`) assert.Contains(t, string(raw), `"value":"CVE-2021-9999"`) assert.Contains(t, string(raw), `"details":`) - assert.Contains(t, string(raw), `"type":"named-list"`) + assert.Contains(t, string(raw), `"reachable":{`) assert.Contains(t, string(raw), `"name":"Reachable"`) assert.Contains(t, string(raw), `"value":"CVE-2021-9999 (Not Covered)."`) + assert.NotContains(t, string(raw), `"details":{"type":"named-list"`) assert.Contains(t, string(raw), `"type":"cwe"`) assert.Contains(t, string(raw), `"name":"CWE-1004"`) assert.Contains(t, string(raw), `"value":"CWE-1004"`) diff --git a/utils/utils.go b/utils/utils.go index f573a3804..601f57d6e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -494,7 +494,7 @@ func writeCycloneDxToDir(outputDir string, scanResults *results.SecurityCommandR } fullBom, err := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ HasViolationContext: scanResults.HasViolationContext(), - IncludeVulnerabilities: scanResults.IncludesVulnerabilities(), + IncludeVulnerabilities: true, IncludeSbom: true, }).ConvertToCycloneDx(scanResults) if err != nil { From 7a97bee506c2f5e4a759bf3e25b7ab05c1e47248 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 29 Apr 2026 12:28:32 +0300 Subject: [PATCH 09/17] with reachability field --- .../cyclonedx_gitlab_reachability.go | 206 ++++++++++++++++++ .../cyclonedx_gitlab_reachability_test.go | 60 +++++ utils/gitlabreport/gitlabreport.go | 72 +----- utils/gitlabreport/gitlabreport_test.go | 23 +- utils/utils.go | 1 + 5 files changed, 284 insertions(+), 78 deletions(-) create mode 100644 utils/gitlabreport/cyclonedx_gitlab_reachability.go create mode 100644 utils/gitlabreport/cyclonedx_gitlab_reachability_test.go diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go new file mode 100644 index 000000000..572763660 --- /dev/null +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -0,0 +1,206 @@ +package gitlabreport + +import ( + "fmt" + "strings" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/formats/cdxutils" + "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-client-go/utils/log" +) + +// GitLab documents native "Reachable" (Yes / Not Found / Not Available) from CycloneDX +// component properties, not from gl-dependency-scanning-report.json details. +// See: https://docs.gitlab.com/development/sec/cyclonedx_property_taxonomy/ +const ( + gitlabMetaSchemaVersionProp = "gitlab:meta:schema_version" + gitlabDependencyScanningInputFilePath = "gitlab:dependency_scanning:input_file:path" + gitlabDependencyScanningReachability = "gitlab:dependency_scanning_component:reachability" + gitlabReachabilityInUse = "in_use" + gitlabReachabilityNotFound = "not_found" +) + +// reachRank orders contextual-analysis outcomes for merging multiple findings on one dependency. +type reachRank int + +const ( + reachNone reachRank = iota + reachNotFound + reachInUse +) + +// EnrichCycloneDXBOMForGitLabReachability adds GitLab CycloneDX properties so the Security UI +// "Reachable" field reflects JFrog contextual analysis (applicability): Applicable → in_use (Yes), +// other assessed outcomes → not_found (Not Found), no applicability data → omit (Not Available). +// +// GitLab only merges SBOM reachability into findings when the BOM is uploaded as a CycloneDX +// report (artifacts:reports:cyclonedx), not as a generic artifact path alone. +func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *results.SecurityCommandResults) { + if bom == nil || scanResults == nil { + return + } + if bom.Metadata == nil { + bom.Metadata = &cyclonedx.Metadata{} + } + bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{ + Name: gitlabMetaSchemaVersionProp, + Value: "1", + }) + + convertor := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ + IncludeVulnerabilities: true, + HasViolationContext: scanResults.HasViolationContext(), + }) + simpleJSON, err := convertor.ConvertToSimpleJson(scanResults) + if err != nil { + log.Warn(fmt.Sprintf("GitLab reachability: skipping CycloneDX enrichment, simple JSON conversion failed: %v", err)) + return + } + + depInfo := make(map[string]*depReachInfo) + for i := range simpleJSON.Vulnerabilities { + mergeRowReachability(depInfo, &simpleJSON.Vulnerabilities[i]) + } + for i := range simpleJSON.SecurityViolations { + mergeRowReachability(depInfo, &simpleJSON.SecurityViolations[i]) + } + + if bom.Metadata.Component != nil { + walkComponentTree(bom.Metadata.Component, depInfo) + } + walkComponentSlice(bom.Components, depInfo) +} + +// depReachInfo holds merged reachability and a manifest path for GitLab SBOM correlation. +type depReachInfo struct { + rank reachRank + inputFile string +} + +func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.VulnerabilityOrViolationRow) { + r, ok := gitlabReachabilityRankForRow(v) + if !ok { + return + } + inFile := rowPreferredInputFile(v) + for _, key := range rowDependencyKeys(v) { + if key == "" { + continue + } + cur := depInfo[key] + if cur == nil { + cur = &depReachInfo{} + depInfo[key] = cur + } + if r > cur.rank { + cur.rank = r + if inFile != "" { + cur.inputFile = inFile + } + } else if r == cur.rank && cur.inputFile == "" && inFile != "" { + cur.inputFile = inFile + } + } +} + +// rowPreferredInputFile picks a repo-relative lock/manifest path for gitlab:dependency_scanning:input_file:path. +func rowPreferredInputFile(v *formats.VulnerabilityOrViolationRow) string { + for _, comp := range v.Components { + if comp.PreferredLocation != nil { + if f := strings.TrimSpace(comp.PreferredLocation.File); f != "" { + return f + } + } + for _, ev := range comp.Evidences { + if f := strings.TrimSpace(ev.File); f != "" { + return f + } + } + } + return strings.TrimSpace(manifestFileForTechnology(v.Technology)) +} + +func rowDependencyKeys(v *formats.VulnerabilityOrViolationRow) []string { + name := strings.TrimSpace(v.ImpactedDependencyName) + ver := strings.TrimSpace(v.ImpactedDependencyVersion) + if name == "" { + return nil + } + return []string{dependencyReachabilityKey(name, ver)} +} + +func dependencyReachabilityKey(name, version string) string { + return name + "\x00" + version +} + +// gitlabReachabilityRankForRow maps aggregated contextual analysis to GitLab reachability ranks. +func gitlabReachabilityRankForRow(v *formats.VulnerabilityOrViolationRow) (reachRank, bool) { + switch rowFinalApplicabilityStatus(v) { + case jasutils.NotScanned: + return reachNone, false + case jasutils.Applicable: + return reachInUse, true + default: + return reachNotFound, true + } +} + +func walkComponentSlice(list *[]cyclonedx.Component, depInfo map[string]*depReachInfo) { + if list == nil { + return + } + for i := range *list { + walkComponentTree(&(*list)[i], depInfo) + } +} + +func walkComponentTree(c *cyclonedx.Component, depInfo map[string]*depReachInfo) { + if c == nil { + return + } + if info := bestReachInfoForComponent(c, depInfo); info != nil && info.rank > reachNone { + if info.inputFile != "" { + c.Properties = cdxutils.AppendProperties(c.Properties, cyclonedx.Property{ + Name: gitlabDependencyScanningInputFilePath, + Value: info.inputFile, + }) + } + val := gitlabReachabilityNotFound + if info.rank == reachInUse { + val = gitlabReachabilityInUse + } + c.Properties = cdxutils.AppendProperties(c.Properties, cyclonedx.Property{ + Name: gitlabDependencyScanningReachability, + Value: val, + }) + } + walkComponentSlice(c.Components, depInfo) +} + +func bestReachInfoForComponent(c *cyclonedx.Component, depInfo map[string]*depReachInfo) *depReachInfo { + var best *depReachInfo + for _, key := range componentDependencyMatchKeys(c) { + if cur := depInfo[key]; cur != nil && (best == nil || cur.rank > best.rank) { + best = cur + } + } + return best +} + +func componentDependencyMatchKeys(c *cyclonedx.Component) []string { + name := strings.TrimSpace(c.Name) + ver := strings.TrimSpace(c.Version) + if name == "" { + return nil + } + var keys []string + if g := strings.TrimSpace(c.Group); g != "" { + keys = append(keys, dependencyReachabilityKey(g+":"+name, ver)) + } + keys = append(keys, dependencyReachabilityKey(name, ver)) + return keys +} diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go b/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go new file mode 100644 index 000000000..06281dc48 --- /dev/null +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go @@ -0,0 +1,60 @@ +package gitlabreport + +import ( + "testing" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/jasutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitlabReachabilityRankForRow(t *testing.T) { + t.Run("not scanned omits property", func(t *testing.T) { + v := formats.VulnerabilityOrViolationRow{ + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "lib", ImpactedDependencyVersion: "1.0.0", + }, + Cves: []formats.CveRow{{Id: "CVE-2024-1"}}, + } + r, ok := gitlabReachabilityRankForRow(&v) + assert.False(t, ok) + assert.Equal(t, reachNone, r) + }) + t.Run("applicable is in_use", func(t *testing.T) { + v := formats.VulnerabilityOrViolationRow{ + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "lib", ImpactedDependencyVersion: "1.0.0", + }, + Cves: []formats.CveRow{{ + Id: "CVE-2024-1", + Applicability: &formats.Applicability{Status: jasutils.Applicable.String()}, + }}, + } + r, ok := gitlabReachabilityRankForRow(&v) + require.True(t, ok) + assert.Equal(t, reachInUse, r) + }) + t.Run("not applicable is not_found", func(t *testing.T) { + v := formats.VulnerabilityOrViolationRow{ + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "lib", ImpactedDependencyVersion: "1.0.0", + }, + Cves: []formats.CveRow{{ + Id: "CVE-2024-1", + Applicability: &formats.Applicability{Status: jasutils.NotApplicable.String()}, + }}, + } + r, ok := gitlabReachabilityRankForRow(&v) + require.True(t, ok) + assert.Equal(t, reachNotFound, r) + }) +} + +func TestEnrichCycloneDXBOMForGitLabReachability_nilSafe(t *testing.T) { + EnrichCycloneDXBOMForGitLabReachability(nil, nil) + bom := &cyclonedx.BOM{} + EnrichCycloneDXBOMForGitLabReachability(bom, nil) + assert.Nil(t, bom.Metadata) +} diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 05b27522d..4b727d656 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -56,15 +56,17 @@ type Vendor struct { } 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"` - Details map[string]DetailNamedListItem `json:"details,omitempty"` // named-list *items* only; see schema note on buildReachabilityDetails + 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"` + // Details is optional per GitLab schema (named-list item map). Reachable is set on CycloneDX + // components (gitlab:dependency_scanning_component:reachability), not here. + Details map[string]DetailNamedListItem `json:"details,omitempty"` } // DetailNamedListItem merges named_field (name) with a detail payload (e.g. type "text" + value). @@ -206,11 +208,6 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability // contextual analysis in name so it appears in the list; description holds summary only. name := buildVulnerabilityNameWithContextualAnalysis(v) desc := strings.TrimSpace(getSummary(v)) - reach := contextualAnalysisReachabilityText(v) - var details map[string]DetailNamedListItem - if strings.TrimSpace(reach) != "" { - details = buildReachabilityDetails(reach) - } solution := "" if len(v.FixedVersions) > 0 { solution = fmt.Sprintf("Upgrade %s to version %s or later.", v.ImpactedDependencyName, v.FixedVersions[0]) @@ -230,7 +227,6 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability Identifiers: identifiers, Location: location, Links: links, - Details: details, } } @@ -411,52 +407,6 @@ func aggregatedContextualAnalysisDisplay(v *formats.VulnerabilityOrViolationRow) return st.String() } -// contextualAnalysisReachabilityText returns contextual analysis (JAS applicability) for the -// Reachable detail field: per-CVE lines when available, otherwise the row-level status when the -// finding has a titled vulnerability. -func contextualAnalysisReachabilityText(v *formats.VulnerabilityOrViolationRow) string { - if s := contextualAnalysisDescriptionPrefix(v); strings.TrimSpace(s) != "" { - return strings.TrimSpace(s) - } - if buildVulnerabilityNameWithContextualAnalysis(v) == "" { - return "" - } - return aggregatedContextualAnalysisDisplay(v) -} - -func buildReachabilityDetails(reachabilityText string) map[string]DetailNamedListItem { - return map[string]DetailNamedListItem{ - "reachable": { - Name: "Reachable", - Type: "text", - Value: reachabilityText, - }, - } -} - -// 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])) diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index 1e35a3ac1..9e1a477ed 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -207,27 +207,19 @@ func TestVulnerabilityToReport(t *testing.T) { assert.Equal(t, tt.wantManifest, got.Location.File) if tt.name == "CVE name and link" { assert.Equal(t, "Test summary", got.Description) - require.Contains(t, got.Details, "reachable") - item := got.Details["reachable"] - assert.Equal(t, "Reachable", item.Name) - assert.Equal(t, "text", item.Type) - assert.Equal(t, "CVE-2021-1234 (Not Covered).", item.Value) + assert.Nil(t, got.Details) } if tt.name == "contextual analysis in reachable detail" { assert.Equal(t, "Details here", got.Description) - require.Contains(t, got.Details, "reachable") - item := got.Details["reachable"] - assert.Equal(t, "CVE-2023-1 (Applicable). CVE-2023-2 (Not Applicable).", item.Value) + assert.Nil(t, got.Details) } if tt.name == "non-CVE issue id adds xray identifier" { assert.Empty(t, got.Description) - require.Contains(t, got.Details, "reachable") - assert.Equal(t, jasutils.NotCovered.String(), got.Details["reachable"].Value) + assert.Nil(t, got.Details) } if tt.name == "CVE from issueId when cves slice empty" { assert.Equal(t, "from issue id only", got.Description) - require.Contains(t, got.Details, "reachable") - assert.Equal(t, jasutils.NotCovered.String(), got.Details["reachable"].Value) + assert.Nil(t, got.Details) } if tt.name == "fallback identifier when no CVE or issue id" { assert.Nil(t, got.Details) @@ -262,11 +254,8 @@ func TestGitLabReportJSON_identifierValueIsCVE(t *testing.T) { assert.Contains(t, string(raw), `"type":"cve"`) assert.Contains(t, string(raw), `"name":"CVE-2021-9999"`) assert.Contains(t, string(raw), `"value":"CVE-2021-9999"`) - assert.Contains(t, string(raw), `"details":`) - assert.Contains(t, string(raw), `"reachable":{`) - assert.Contains(t, string(raw), `"name":"Reachable"`) - assert.Contains(t, string(raw), `"value":"CVE-2021-9999 (Not Covered)."`) - assert.NotContains(t, string(raw), `"details":{"type":"named-list"`) + assert.NotContains(t, string(raw), `"reachable"`) + assert.NotContains(t, string(raw), `"details":`) assert.Contains(t, string(raw), `"type":"cwe"`) assert.Contains(t, string(raw), `"name":"CWE-1004"`) assert.Contains(t, string(raw), `"value":"CWE-1004"`) diff --git a/utils/utils.go b/utils/utils.go index 601f57d6e..6691968d8 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -501,6 +501,7 @@ func writeCycloneDxToDir(outputDir string, scanResults *results.SecurityCommandR return fmt.Errorf("convert to CycloneDX: %w", err) } bom := fullBom.BOM + gitlabreport.EnrichCycloneDXBOMForGitLabReachability(&bom, scanResults) path := filepath.Join(outputDir, cyclonedxOutputFilename) f, err := os.Create(path) if err != nil { From 236f0153ac76c0cf0188d23a4ddb0bd4866992fa Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 30 Apr 2026 11:04:51 +0300 Subject: [PATCH 10/17] with reachability field --- .../cyclonedx_gitlab_reachability.go | 35 +++++++++++++++++++ utils/gitlabreport/gitlabreport.go | 6 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go index 572763660..c34958d42 100644 --- a/utils/gitlabreport/cyclonedx_gitlab_reachability.go +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -69,6 +69,15 @@ func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *re mergeRowReachability(depInfo, &simpleJSON.SecurityViolations[i]) } + // When the whole scan uses one lockfile (typical), set it on metadata too so GitLab can + // correlate SBOM reachability with dependency-scanning findings (per taxonomy). + if unique := uniqueNonEmptyInputFilesFromDepInfo(depInfo); len(unique) == 1 { + bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{ + Name: gitlabDependencyScanningInputFilePath, + Value: unique[0], + }) + } + if bom.Metadata.Component != nil { walkComponentTree(bom.Metadata.Component, depInfo) } @@ -81,6 +90,32 @@ type depReachInfo struct { inputFile string } +func uniqueNonEmptyInputFilesFromDepInfo(depInfo map[string]*depReachInfo) []string { + seen := make(map[string]struct{}) + for _, info := range depInfo { + if info == nil || info.rank <= reachNone { + continue + } + f := strings.TrimSpace(info.inputFile) + if f == "" { + continue + } + seen[f] = struct{}{} + } + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for f := range seen { + out = append(out, f) + } + if len(out) == 1 { + return out + } + // Multiple lockfiles in one BOM: do not guess metadata-level path. + return nil +} + func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.VulnerabilityOrViolationRow) { r, ok := gitlabReachabilityRankForRow(v) if !ok { diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 4b727d656..8d9f8c0f2 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -195,8 +195,12 @@ func formatGitLabTime(t time.Time) string { func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) VulnerabilityReport { id := deterministicVulnID(v.ImpactedDependencyName, v.ImpactedDependencyVersion, v.IssueId, v.Cves) identifiers := buildIdentifiers(v) + manifestPath := rowPreferredInputFile(v) + if manifestPath == "" { + manifestPath = manifestFileForTechnology(v.Technology) + } location := Location{ - File: manifestFileForTechnology(v.Technology), + File: manifestPath, Dependency: Dependency{ Package: Package{Name: v.ImpactedDependencyName}, Version: v.ImpactedDependencyVersion, From d2a873a3248f0e40c18f224cd25f3709afba1b66 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 7 May 2026 09:51:51 +0300 Subject: [PATCH 11/17] fix Reachabe field --- utils/gitlabreport/cyclonedx_gitlab_reachability.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go index c34958d42..bfeb2223d 100644 --- a/utils/gitlabreport/cyclonedx_gitlab_reachability.go +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -197,6 +197,9 @@ func walkComponentTree(c *cyclonedx.Component, depInfo map[string]*depReachInfo) if c == nil { return } + if idx := strings.Index(c.PackageURL, "?"); idx != -1 { + c.PackageURL = c.PackageURL[:idx] + } if info := bestReachInfoForComponent(c, depInfo); info != nil && info.rank > reachNone { if info.inputFile != "" { c.Properties = cdxutils.AppendProperties(c.Properties, cyclonedx.Property{ From fa2b2be3810a0c07d1c7df5ee15c837a2cf4ee34 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 7 May 2026 09:59:06 +0300 Subject: [PATCH 12/17] test fix --- .../cyclonedx_gitlab_reachability_test.go | 51 +++++++++++++++++++ utils/gitlabreport/gitlabreport.go | 7 +-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go b/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go index 06281dc48..53718c445 100644 --- a/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go @@ -58,3 +58,54 @@ func TestEnrichCycloneDXBOMForGitLabReachability_nilSafe(t *testing.T) { EnrichCycloneDXBOMForGitLabReachability(bom, nil) assert.Nil(t, bom.Metadata) } + +func TestWalkComponentTree_setsGitLabPropertiesAndTrimsPurlQuery(t *testing.T) { + depInfo := map[string]*depReachInfo{ + dependencyReachabilityKey("minimist", "1.2.5"): { + rank: reachInUse, + inputFile: "node_modules/minimist/package.json", + }, + } + c := &cyclonedx.Component{ + Type: cyclonedx.ComponentTypeLibrary, + Name: "minimist", + Version: "1.2.5", + PackageURL: "pkg:npm/minimist@1.2.5?foo=bar", + } + + walkComponentTree(c, depInfo) + + assert.Equal(t, "pkg:npm/minimist@1.2.5", c.PackageURL, "query params should be stripped for stable matching") + require.NotNil(t, c.Properties) + props := map[string]string{} + for _, p := range *c.Properties { + props[p.Name] = p.Value + } + assert.Equal(t, "node_modules/minimist/package.json", props[gitlabDependencyScanningInputFilePath]) + assert.Equal(t, gitlabReachabilityInUse, props[gitlabDependencyScanningReachability]) +} + +func TestWalkComponentTree_usesGroupPrefixedNameMatch(t *testing.T) { + depInfo := map[string]*depReachInfo{ + dependencyReachabilityKey("com.thoughtworks.xstream:xstream", "1.4.5"): { + rank: reachNotFound, + inputFile: "pom.xml", + }, + } + c := &cyclonedx.Component{ + Type: cyclonedx.ComponentTypeLibrary, + Group: "com.thoughtworks.xstream", + Name: "xstream", + Version: "1.4.5", + } + + walkComponentTree(c, depInfo) + + require.NotNil(t, c.Properties) + props := map[string]string{} + for _, p := range *c.Properties { + props[p.Name] = p.Value + } + assert.Equal(t, "pom.xml", props[gitlabDependencyScanningInputFilePath]) + assert.Equal(t, gitlabReachabilityNotFound, props[gitlabDependencyScanningReachability]) +} diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 8d9f8c0f2..872addaf9 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -196,9 +196,6 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability id := deterministicVulnID(v.ImpactedDependencyName, v.ImpactedDependencyVersion, v.IssueId, v.Cves) identifiers := buildIdentifiers(v) manifestPath := rowPreferredInputFile(v) - if manifestPath == "" { - manifestPath = manifestFileForTechnology(v.Technology) - } location := Location{ File: manifestPath, Dependency: Dependency{ @@ -207,9 +204,7 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability }, } severity := normalizeSeverity(getSeverity(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 holds summary only. + // Keep contextual analysis in title so it shows in GitLab's vulnerability list. name := buildVulnerabilityNameWithContextualAnalysis(v) desc := strings.TrimSpace(getSummary(v)) solution := "" From 05a75ebdd5ffdfa62bbbdb4d0022209cde4d12de Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 7 May 2026 10:36:43 +0300 Subject: [PATCH 13/17] after cr --- .../cyclonedx_gitlab_reachability.go | 10 +--- utils/gitlabreport/gitlabreport.go | 46 +++++++------------ 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go index bfeb2223d..8e1977e64 100644 --- a/utils/gitlabreport/cyclonedx_gitlab_reachability.go +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -34,11 +34,8 @@ const ( ) // EnrichCycloneDXBOMForGitLabReachability adds GitLab CycloneDX properties so the Security UI -// "Reachable" field reflects JFrog contextual analysis (applicability): Applicable → in_use (Yes), -// other assessed outcomes → not_found (Not Found), no applicability data → omit (Not Available). -// -// GitLab only merges SBOM reachability into findings when the BOM is uploaded as a CycloneDX -// report (artifacts:reports:cyclonedx), not as a generic artifact path alone. +// "Reachable" field reflects JFrog contextual analysis: Applicable → in_use, other assessed → not_found, +// no applicability data → omitted (shows as "Not Available"). func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *results.SecurityCommandResults) { if bom == nil || scanResults == nil { return @@ -84,7 +81,6 @@ func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *re walkComponentSlice(bom.Components, depInfo) } -// depReachInfo holds merged reachability and a manifest path for GitLab SBOM correlation. type depReachInfo struct { rank reachRank inputFile string @@ -142,7 +138,6 @@ func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.Vulnerabi } } -// rowPreferredInputFile picks a repo-relative lock/manifest path for gitlab:dependency_scanning:input_file:path. func rowPreferredInputFile(v *formats.VulnerabilityOrViolationRow) string { for _, comp := range v.Components { if comp.PreferredLocation != nil { @@ -172,7 +167,6 @@ func dependencyReachabilityKey(name, version string) string { return name + "\x00" + version } -// gitlabReachabilityRankForRow maps aggregated contextual analysis to GitLab reachability ranks. func gitlabReachabilityRankForRow(v *formats.VulnerabilityOrViolationRow) (reachRank, bool) { switch rowFinalApplicabilityStatus(v) { case jasutils.NotScanned: diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 872addaf9..32e518d7b 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -37,10 +37,10 @@ type DependencyScanningReport struct { type ScanReport struct { Analyzer AnalyzerScanner `json:"analyzer"` Scanner AnalyzerScanner `json:"scanner"` - StartTime string `json:"start_time"` // ISO8601 UTC yyyy-mm-ddThh:mm:ss + StartTime string `json:"start_time"` EndTime string `json:"end_time"` - Status string `json:"status"` // "success" or "failure" - Type string `json:"type"` // "dependency_scanning" + Status string `json:"status"` + Type string `json:"type"` } type AnalyzerScanner struct { @@ -56,20 +56,17 @@ type Vendor struct { } 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"` - // Details is optional per GitLab schema (named-list item map). Reachable is set on CycloneDX - // components (gitlab:dependency_scanning_component:reachability), not here. - Details map[string]DetailNamedListItem `json:"details,omitempty"` -} - -// DetailNamedListItem merges named_field (name) with a detail payload (e.g. type "text" + value). + ID string `json:"id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Severity string `json:"severity,omitempty"` + Solution string `json:"solution,omitempty"` + Identifiers []Identifier `json:"identifiers"` + Location Location `json:"location"` + Links []Link `json:"links,omitempty"` + Details map[string]DetailNamedListItem `json:"details,omitempty"` +} + type DetailNamedListItem struct { Name string `json:"name"` Type string `json:"type"` // "text" @@ -120,9 +117,6 @@ func ConvertToGitLabDependencyScanningReport(scanResults *results.SecurityComman }, nil } - // Always include SCA vulnerabilities in this export. scanResults.IncludesVulnerabilities() - // reflects the interactive scan context (e.g. violation-only views); when false, jfrog-cli-security - // skips ParseCVEs and the GitLab JSON would be empty even with many SBOM CVEs — wrong for CI reports. convertor := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{ IncludeVulnerabilities: true, HasViolationContext: scanResults.HasViolationContext(), @@ -204,7 +198,6 @@ func vulnerabilityToReport(v *formats.VulnerabilityOrViolationRow) Vulnerability }, } severity := normalizeSeverity(getSeverity(v)) - // Keep contextual analysis in title so it shows in GitLab's vulnerability list. name := buildVulnerabilityNameWithContextualAnalysis(v) desc := strings.TrimSpace(getSummary(v)) solution := "" @@ -288,8 +281,6 @@ func buildIdentifiers(v *formats.VulnerabilityOrViolationRow) []Identifier { return appendUniqueCWEIdentifiers(ids, v) } -// appendUniqueCWEIdentifiers adds GitLab dependency-scanning identifiers with type "cwe" from each -// CVE row's Cwe list (Xray simple JSON). GitLab aggregates these for dashboards such as "Top 10 CWEs". func appendUniqueCWEIdentifiers(ids []Identifier, v *formats.VulnerabilityOrViolationRow) []Identifier { seen := make(map[string]struct{}) for _, id := range ids { @@ -342,7 +333,6 @@ func normalizeCweID(raw string) string { return "" } -// cweNumericID returns digits-only CWE id, or empty if invalid. func cweNumericID(s string) string { s = strings.TrimSpace(s) if s == "" { @@ -384,8 +374,6 @@ 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 != "" { @@ -397,7 +385,6 @@ func buildVulnerabilityNameWithContextualAnalysis(v *formats.VulnerabilityOrViol 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() == "" { @@ -440,7 +427,6 @@ func severitySortRank(normalized string) int { } } -// 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 { @@ -451,7 +437,6 @@ func rowFinalApplicabilityStatus(v *formats.VulnerabilityOrViolationRow) jasutil 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: @@ -492,6 +477,8 @@ func manifestFileForTechnology(tech techutils.Technology) string { switch tech { case techutils.Npm, techutils.Yarn: return "package-lock.json" + case techutils.Pnpm: + return "pnpm-lock.yaml" case techutils.Go: return "go.sum" case techutils.Pip, techutils.Pipenv: @@ -505,7 +492,6 @@ func manifestFileForTechnology(tech techutils.Technology) string { } } -// 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") From e6bf437bd1c6d4c22b9efa9a00e88a22c4f086f5 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 7 May 2026 10:42:04 +0300 Subject: [PATCH 14/17] after cr --- .../cyclonedx_gitlab_reachability.go | 69 ++++++++----------- utils/gitlabreport/gitlabreport.go | 13 ++-- 2 files changed, 32 insertions(+), 50 deletions(-) diff --git a/utils/gitlabreport/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go index 8e1977e64..dc4c6417f 100644 --- a/utils/gitlabreport/cyclonedx_gitlab_reachability.go +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -68,10 +68,10 @@ func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *re // When the whole scan uses one lockfile (typical), set it on metadata too so GitLab can // correlate SBOM reachability with dependency-scanning findings (per taxonomy). - if unique := uniqueNonEmptyInputFilesFromDepInfo(depInfo); len(unique) == 1 { + if f := uniqueInputFileFromDepInfo(depInfo); f != "" { bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{ Name: gitlabDependencyScanningInputFilePath, - Value: unique[0], + Value: f, }) } @@ -86,30 +86,24 @@ type depReachInfo struct { inputFile string } -func uniqueNonEmptyInputFilesFromDepInfo(depInfo map[string]*depReachInfo) []string { +// uniqueInputFileFromDepInfo returns the single lock/manifest file shared by all assessed dependencies, +// or empty if there are zero or more than one (multiple lockfiles: do not guess metadata-level path). +func uniqueInputFileFromDepInfo(depInfo map[string]*depReachInfo) string { seen := make(map[string]struct{}) for _, info := range depInfo { if info == nil || info.rank <= reachNone { continue } - f := strings.TrimSpace(info.inputFile) - if f == "" { - continue + if f := strings.TrimSpace(info.inputFile); f != "" { + seen[f] = struct{}{} } - seen[f] = struct{}{} - } - if len(seen) == 0 { - return nil } - out := make([]string, 0, len(seen)) - for f := range seen { - out = append(out, f) - } - if len(out) == 1 { - return out + if len(seen) == 1 { + for f := range seen { + return f + } } - // Multiple lockfiles in one BOM: do not guess metadata-level path. - return nil + return "" } func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.VulnerabilityOrViolationRow) { @@ -117,24 +111,24 @@ func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.Vulnerabi if !ok { return } + name := strings.TrimSpace(v.ImpactedDependencyName) + if name == "" { + return + } + key := dependencyReachabilityKey(name, strings.TrimSpace(v.ImpactedDependencyVersion)) inFile := rowPreferredInputFile(v) - for _, key := range rowDependencyKeys(v) { - if key == "" { - continue - } - cur := depInfo[key] - if cur == nil { - cur = &depReachInfo{} - depInfo[key] = cur - } - if r > cur.rank { - cur.rank = r - if inFile != "" { - cur.inputFile = inFile - } - } else if r == cur.rank && cur.inputFile == "" && inFile != "" { + cur := depInfo[key] + if cur == nil { + cur = &depReachInfo{} + depInfo[key] = cur + } + if r > cur.rank { + cur.rank = r + if inFile != "" { cur.inputFile = inFile } + } else if r == cur.rank && cur.inputFile == "" && inFile != "" { + cur.inputFile = inFile } } @@ -154,15 +148,6 @@ func rowPreferredInputFile(v *formats.VulnerabilityOrViolationRow) string { return strings.TrimSpace(manifestFileForTechnology(v.Technology)) } -func rowDependencyKeys(v *formats.VulnerabilityOrViolationRow) []string { - name := strings.TrimSpace(v.ImpactedDependencyName) - ver := strings.TrimSpace(v.ImpactedDependencyVersion) - if name == "" { - return nil - } - return []string{dependencyReachabilityKey(name, ver)} -} - func dependencyReachabilityKey(name, version string) string { return name + "\x00" + version } diff --git a/utils/gitlabreport/gitlabreport.go b/utils/gitlabreport/gitlabreport.go index 32e518d7b..44dbe4553 100644 --- a/utils/gitlabreport/gitlabreport.go +++ b/utils/gitlabreport/gitlabreport.go @@ -69,7 +69,7 @@ type VulnerabilityReport struct { type DetailNamedListItem struct { Name string `json:"name"` - Type string `json:"type"` // "text" + Type string `json:"type"` Value string `json:"value"` } @@ -382,15 +382,12 @@ func buildVulnerabilityNameWithContextualAnalysis(v *formats.VulnerabilityOrViol if base == "" { return "" } - return fmt.Sprintf("%s (%s)", base, aggregatedContextualAnalysisDisplay(v)) -} - -func aggregatedContextualAnalysisDisplay(v *formats.VulnerabilityOrViolationRow) string { st := rowFinalApplicabilityStatus(v) - if st == jasutils.NotScanned || st.String() == "" { - return jasutils.NotCovered.String() + display := st.String() + if st == jasutils.NotScanned || display == "" { + display = jasutils.NotCovered.String() } - return st.String() + return fmt.Sprintf("%s (%s)", base, display) } func sortVulnerabilityRowsForGitLab(vulns []formats.VulnerabilityOrViolationRow) { From f77ae12f12edccb16c3d9639cbb5f0055abce21b Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 7 May 2026 10:52:11 +0300 Subject: [PATCH 15/17] after cr --- utils/gitlabreport/gitlabreport_test.go | 40 ++++++++----------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/utils/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go index 9e1a477ed..e64f4da7f 100644 --- a/utils/gitlabreport/gitlabreport_test.go +++ b/utils/gitlabreport/gitlabreport_test.go @@ -98,10 +98,10 @@ func TestMakeAnalyzerScanner(t *testing.T) { func TestVulnerabilityToReport(t *testing.T) { tests := []struct { - name string - row formats.VulnerabilityOrViolationRow - // spot checks + name string + row formats.VulnerabilityOrViolationRow wantName string + wantDescription string wantSeverity string wantManifest string wantSolution string @@ -122,13 +122,14 @@ func TestVulnerabilityToReport(t *testing.T) { FixedVersions: []string{"4.17.21"}, }, wantName: "CVE-2021-1234 (Not Covered)", + wantDescription: "Test summary", wantSeverity: "High", wantManifest: "package-lock.json", wantSolution: "Upgrade lodash to version 4.17.21 or later.", identifierTypes: []string{"cve", "xray", "cwe"}, }, { - name: "contextual analysis in reachable detail", + name: "contextual analysis in name", row: formats.VulnerabilityOrViolationRow{ IssueId: "XRAY-99", ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ @@ -144,6 +145,7 @@ func TestVulnerabilityToReport(t *testing.T) { Technology: techutils.Npm, }, wantName: "CVE-2023-1 (Applicable)", + wantDescription: "Details here", wantSeverity: "Low", wantManifest: "package-lock.json", identifierTypes: []string{"cve", "cve", "xray", "cwe", "cwe"}, @@ -161,6 +163,7 @@ func TestVulnerabilityToReport(t *testing.T) { Technology: techutils.Go, }, wantName: "XRAY-100 (Not Covered)", + wantDescription: "", wantSeverity: "Low", wantManifest: "go.sum", identifierTypes: []string{"xray", "cwe"}, @@ -176,12 +179,13 @@ func TestVulnerabilityToReport(t *testing.T) { Technology: techutils.Maven, }, wantName: "", + wantDescription: "", wantSeverity: "Unknown", wantManifest: "pom.xml", identifierTypes: []string{"other", "cwe"}, }, { - name: "CVE from issueId when cves slice empty", + name: "CVE from issueId when cves slice has no id", row: formats.VulnerabilityOrViolationRow{ IssueId: "CVE-2020-9999", ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ @@ -194,6 +198,7 @@ func TestVulnerabilityToReport(t *testing.T) { Technology: techutils.Maven, }, wantName: "CVE-2020-9999 (Not Covered)", + wantDescription: "from issue id only", wantSeverity: "High", wantManifest: "pom.xml", identifierTypes: []string{"cve", "cwe"}, @@ -203,30 +208,11 @@ func TestVulnerabilityToReport(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := vulnerabilityToReport(&tt.row) assert.Equal(t, tt.wantName, got.Name) + assert.Equal(t, tt.wantDescription, got.Description) 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, "Test summary", got.Description) - assert.Nil(t, got.Details) - } - if tt.name == "contextual analysis in reachable detail" { - assert.Equal(t, "Details here", got.Description) - assert.Nil(t, got.Details) - } - if tt.name == "non-CVE issue id adds xray identifier" { - assert.Empty(t, got.Description) - assert.Nil(t, got.Details) - } - if tt.name == "CVE from issueId when cves slice empty" { - assert.Equal(t, "from issue id only", got.Description) - assert.Nil(t, got.Details) - } - if tt.name == "fallback identifier when no CVE or issue id" { - assert.Nil(t, got.Details) - } - if tt.wantSolution != "" { - assert.Equal(t, tt.wantSolution, got.Solution) - } + assert.Equal(t, tt.wantSolution, got.Solution) + assert.Nil(t, got.Details) require.Len(t, got.Identifiers, len(tt.identifierTypes)) for i, wantType := range tt.identifierTypes { assert.Equal(t, wantType, got.Identifiers[i].Type) From e839858b260a828175c7b07f8a4a06147b572b94 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Sun, 10 May 2026 11:00:11 +0300 Subject: [PATCH 16/17] after cr --- scanrepository/scanrepository.go | 18 +++++++++++------- utils/utils.go | 11 ++--------- utils/utils_test.go | 4 ++-- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/scanrepository/scanrepository.go b/scanrepository/scanrepository.go index 00d9db25c..85f9c4c0d 100644 --- a/scanrepository/scanrepository.go +++ b/scanrepository/scanrepository.go @@ -154,13 +154,7 @@ 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 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())) - } - } + sr.uploadGitLabScanResultsIfNeeded(repository, scanResults) 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 configuration settings in Jfrog platform", createAutoFixPrConfigNameInProfile)) @@ -193,6 +187,16 @@ func getTotalFindingsFromScanResults(scanResults *results.SecurityCommandResults return findingCount } +func (sr *ScanRepositoryCmd) uploadGitLabScanResultsIfNeeded(repository *utils.Repository, scanResults *results.SecurityCommandResults) { + if repository.Params.Git.GitProvider != vcsutils.GitLab || repository.Params.Git.GitlabScanResultsOutputDir == "" { + return + } + log.Debug(fmt.Sprintf("Trying to save scan results to directory: %s", repository.Params.Git.GitlabScanResultsOutputDir)) + if writeErr := utils.WriteScanResultsToGitlabDir(repository.Params.Git.GitlabScanResultsOutputDir, scanResults, sr.scanDetails.StartTime); writeErr != nil { + log.Warn(fmt.Sprintf("Failed to write scan results to directory: %s", writeErr.Error())) + } +} + func (sr *ScanRepositoryCmd) uploadResultsToGithubDashboardsIfNeeded(repository *utils.Repository, scanResults *results.SecurityCommandResults) { if repository.Params.Git.GitProvider.String() == vcsutils.GitHub.String() { // Uploads Sarif results to GitHub in order to view the scan in the code scanning UI diff --git a/utils/utils.go b/utils/utils.go index 6691968d8..c3a16b2c5 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -15,7 +15,6 @@ import ( "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" @@ -465,7 +464,7 @@ func CreateErrorIfFailUponScannerErrorEnabled(fail bool, messageForLog string, e return err } -func WriteScanResultsToDir(outputDir string, scanResults *results.SecurityCommandResults, startTime time.Time) error { +func WriteScanResultsToGitlabDir(outputDir string, scanResults *results.SecurityCommandResults, startTime time.Time) error { if outputDir == "" { return fmt.Errorf("output directory is required") } @@ -503,13 +502,7 @@ func writeCycloneDxToDir(outputDir string, scanResults *results.SecurityCommandR bom := fullBom.BOM gitlabreport.EnrichCycloneDXBOMForGitLabReachability(&bom, scanResults) 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 { + if err = utils.SaveCdxContentToFile(path, &bom); err != nil { return fmt.Errorf("encode CycloneDX: %w", err) } log.Info(fmt.Sprintf("CycloneDX SBOM written to %s", path)) diff --git a/utils/utils_test.go b/utils/utils_test.go index d83551b4c..8c3f0a952 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -564,7 +564,7 @@ func createTestSecurityCommandResults() *results.SecurityCommandResults { return scanResults } -func TestWriteScanResultsToDir(t *testing.T) { +func TestWriteScanResultsToGitlabDir(t *testing.T) { start := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) tests := []struct { @@ -622,7 +622,7 @@ func TestWriteScanResultsToDir(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := tt.outputDir(t) - err := WriteScanResultsToDir(dir, tt.scanResults, start) + err := WriteScanResultsToGitlabDir(dir, tt.scanResults, start) if tt.wantErr { assert.Error(t, err) assert.Contains(t, err.Error(), tt.errContains) From 06cec8fc4f8d13fd0cb6e32aacee7aa41f56c52f Mon Sep 17 00:00:00 2001 From: JFrog-Frogbot Date: Sun, 10 May 2026 08:02:42 +0000 Subject: [PATCH 17/17] Upgrade golang.org/x/net to 0.53.0 --- go.mod | 14 +++++++------- go.sum | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 728fae4e4..34cf87736 100644 --- a/go.mod +++ b/go.mod @@ -111,14 +111,14 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/grpc v1.72.1 // indirect diff --git a/go.sum b/go.sum index 01c9dfc9a..ec60ce517 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -347,6 +349,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -362,6 +366,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= @@ -374,6 +380,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -407,6 +415,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -415,6 +425,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -424,6 +436,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -433,6 +447,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=