Skip to content

Commit c030e32

Browse files
authored
Publish Scan Results to Gitlab Dashboards (#1268)
* save results in gitlab format * with tests * static analysis fix * with applicability status * update Identifier column with cve id * reachable column * with cwe * fixed format issues * with reachability field * with reachability field * fix Reachabe field * test fix * after cr * after cr * after cr * after cr
1 parent c6f07fa commit c030e32

9 files changed

Lines changed: 1379 additions & 14 deletions

scanrepository/scanrepository.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"github.com/jfrog/frogbot/v2/packageupdaters"
87
"os"
98
"path/filepath"
109
"regexp"
1110
"strings"
1211

12+
"github.com/jfrog/frogbot/v2/packageupdaters"
13+
1314
"github.com/go-git/go-git/v5"
1415
biutils "github.com/jfrog/build-info-go/utils"
1516

@@ -153,9 +154,10 @@ func (sr *ScanRepositoryCmd) scanAndFixBranch(repository *utils.Repository) (tot
153154
}()
154155
totalFindings = getTotalFindingsFromScanResults(scanResults)
155156
sr.uploadResultsToGithubDashboardsIfNeeded(repository, scanResults)
157+
sr.uploadGitLabScanResultsIfNeeded(repository, scanResults)
156158

157159
if !repository.Params.FrogbotConfig.CreateAutoFixPr {
158-
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))
160+
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))
159161
return totalFindings, nil
160162
}
161163

@@ -185,6 +187,16 @@ func getTotalFindingsFromScanResults(scanResults *results.SecurityCommandResults
185187
return findingCount
186188
}
187189

190+
func (sr *ScanRepositoryCmd) uploadGitLabScanResultsIfNeeded(repository *utils.Repository, scanResults *results.SecurityCommandResults) {
191+
if repository.Params.Git.GitProvider != vcsutils.GitLab || repository.Params.Git.GitlabScanResultsOutputDir == "" {
192+
return
193+
}
194+
log.Debug(fmt.Sprintf("Trying to save scan results to directory: %s", repository.Params.Git.GitlabScanResultsOutputDir))
195+
if writeErr := utils.WriteScanResultsToGitlabDir(repository.Params.Git.GitlabScanResultsOutputDir, scanResults, sr.scanDetails.StartTime); writeErr != nil {
196+
log.Warn(fmt.Sprintf("Failed to write scan results to directory: %s", writeErr.Error()))
197+
}
198+
}
199+
188200
func (sr *ScanRepositoryCmd) uploadResultsToGithubDashboardsIfNeeded(repository *utils.Repository, scanResults *results.SecurityCommandResults) {
189201
if repository.Params.Git.GitProvider.String() == vcsutils.GitHub.String() {
190202
// Uploads Sarif results to GitHub in order to view the scan in the code scanning UI

utils/consts.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ const (
4343
GitDependencyGraphSubmissionEnv = "JF_UPLOAD_SBOM_TO_VCS"
4444

4545
//#nosec G101 -- False positive - no hardcoded credentials.
46-
GitTokenEnv = "JF_GIT_TOKEN"
47-
GitBaseBranchEnv = "JF_GIT_BASE_BRANCH"
48-
GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID"
49-
GitApiEndpointEnv = "JF_GIT_API_ENDPOINT"
46+
GitTokenEnv = "JF_GIT_TOKEN"
47+
GitBaseBranchEnv = "JF_GIT_BASE_BRANCH"
48+
GitPullRequestIDEnv = "JF_GIT_PULL_REQUEST_ID"
49+
GitApiEndpointEnv = "JF_GIT_API_ENDPOINT"
50+
GitlabScanResultsOutputDirEnv = "JF_SCAN_RESULTS_OUTPUT_DIR"
5051

5152
// Placeholders for templates
5253
PackagePlaceHolder = "{IMPACTED_PACKAGE}"

utils/getconfiguration.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,13 @@ func (jp *JFrogPlatform) setJfProjectKeyIfExists() (err error) {
6969
type Git struct {
7070
GitProvider vcsutils.VcsProvider
7171
vcsclient.VcsInfo
72-
RepoOwner string
73-
RepoName string
74-
Branches []string
75-
PullRequestDetails vcsclient.PullRequestInfo
76-
RepositoryCloneUrl string
77-
UploadSbomToVcs *bool
72+
RepoOwner string
73+
RepoName string
74+
Branches []string
75+
PullRequestDetails vcsclient.PullRequestInfo
76+
RepositoryCloneUrl string
77+
UploadSbomToVcs *bool
78+
GitlabScanResultsOutputDir string
7879
}
7980

8081
func (g *Git) GetRepositoryHttpsCloneUrl(gitClient vcsclient.VcsClient) (string, error) {
@@ -96,6 +97,7 @@ func (g *Git) setDefaultsIfNeeded(gitParamsFromEnv *Git, commandName string) (er
9697
g.VcsInfo = gitParamsFromEnv.VcsInfo
9798
g.PullRequestDetails = gitParamsFromEnv.PullRequestDetails
9899
g.RepoName = gitParamsFromEnv.RepoName
100+
g.GitlabScanResultsOutputDir = gitParamsFromEnv.GitlabScanResultsOutputDir
99101

100102
if commandName == ScanPullRequest {
101103
if gitParamsFromEnv.PullRequestDetails.ID == 0 {
@@ -428,6 +430,8 @@ func extractGitParamsFromEnvs() (*Git, error) {
428430
gitEnvParams.PullRequestDetails = vcsclient.PullRequestInfo{ID: int64(convertedPrId)}
429431
}
430432

433+
gitEnvParams.GitlabScanResultsOutputDir = getTrimmedEnv(GitlabScanResultsOutputDirEnv)
434+
431435
return gitEnvParams, nil
432436
}
433437

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package gitlabreport
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/CycloneDX/cyclonedx-go"
8+
"github.com/jfrog/jfrog-cli-security/utils/formats"
9+
"github.com/jfrog/jfrog-cli-security/utils/formats/cdxutils"
10+
"github.com/jfrog/jfrog-cli-security/utils/jasutils"
11+
"github.com/jfrog/jfrog-cli-security/utils/results"
12+
"github.com/jfrog/jfrog-cli-security/utils/results/conversion"
13+
"github.com/jfrog/jfrog-client-go/utils/log"
14+
)
15+
16+
// GitLab documents native "Reachable" (Yes / Not Found / Not Available) from CycloneDX
17+
// component properties, not from gl-dependency-scanning-report.json details.
18+
// See: https://docs.gitlab.com/development/sec/cyclonedx_property_taxonomy/
19+
const (
20+
gitlabMetaSchemaVersionProp = "gitlab:meta:schema_version"
21+
gitlabDependencyScanningInputFilePath = "gitlab:dependency_scanning:input_file:path"
22+
gitlabDependencyScanningReachability = "gitlab:dependency_scanning_component:reachability"
23+
gitlabReachabilityInUse = "in_use"
24+
gitlabReachabilityNotFound = "not_found"
25+
)
26+
27+
// reachRank orders contextual-analysis outcomes for merging multiple findings on one dependency.
28+
type reachRank int
29+
30+
const (
31+
reachNone reachRank = iota
32+
reachNotFound
33+
reachInUse
34+
)
35+
36+
// EnrichCycloneDXBOMForGitLabReachability adds GitLab CycloneDX properties so the Security UI
37+
// "Reachable" field reflects JFrog contextual analysis: Applicable → in_use, other assessed → not_found,
38+
// no applicability data → omitted (shows as "Not Available").
39+
func EnrichCycloneDXBOMForGitLabReachability(bom *cyclonedx.BOM, scanResults *results.SecurityCommandResults) {
40+
if bom == nil || scanResults == nil {
41+
return
42+
}
43+
if bom.Metadata == nil {
44+
bom.Metadata = &cyclonedx.Metadata{}
45+
}
46+
bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{
47+
Name: gitlabMetaSchemaVersionProp,
48+
Value: "1",
49+
})
50+
51+
convertor := conversion.NewCommandResultsConvertor(conversion.ResultConvertParams{
52+
IncludeVulnerabilities: true,
53+
HasViolationContext: scanResults.HasViolationContext(),
54+
})
55+
simpleJSON, err := convertor.ConvertToSimpleJson(scanResults)
56+
if err != nil {
57+
log.Warn(fmt.Sprintf("GitLab reachability: skipping CycloneDX enrichment, simple JSON conversion failed: %v", err))
58+
return
59+
}
60+
61+
depInfo := make(map[string]*depReachInfo)
62+
for i := range simpleJSON.Vulnerabilities {
63+
mergeRowReachability(depInfo, &simpleJSON.Vulnerabilities[i])
64+
}
65+
for i := range simpleJSON.SecurityViolations {
66+
mergeRowReachability(depInfo, &simpleJSON.SecurityViolations[i])
67+
}
68+
69+
// When the whole scan uses one lockfile (typical), set it on metadata too so GitLab can
70+
// correlate SBOM reachability with dependency-scanning findings (per taxonomy).
71+
if f := uniqueInputFileFromDepInfo(depInfo); f != "" {
72+
bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{
73+
Name: gitlabDependencyScanningInputFilePath,
74+
Value: f,
75+
})
76+
}
77+
78+
if bom.Metadata.Component != nil {
79+
walkComponentTree(bom.Metadata.Component, depInfo)
80+
}
81+
walkComponentSlice(bom.Components, depInfo)
82+
}
83+
84+
type depReachInfo struct {
85+
rank reachRank
86+
inputFile string
87+
}
88+
89+
// uniqueInputFileFromDepInfo returns the single lock/manifest file shared by all assessed dependencies,
90+
// or empty if there are zero or more than one (multiple lockfiles: do not guess metadata-level path).
91+
func uniqueInputFileFromDepInfo(depInfo map[string]*depReachInfo) string {
92+
seen := make(map[string]struct{})
93+
for _, info := range depInfo {
94+
if info == nil || info.rank <= reachNone {
95+
continue
96+
}
97+
if f := strings.TrimSpace(info.inputFile); f != "" {
98+
seen[f] = struct{}{}
99+
}
100+
}
101+
if len(seen) == 1 {
102+
for f := range seen {
103+
return f
104+
}
105+
}
106+
return ""
107+
}
108+
109+
func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.VulnerabilityOrViolationRow) {
110+
r, ok := gitlabReachabilityRankForRow(v)
111+
if !ok {
112+
return
113+
}
114+
name := strings.TrimSpace(v.ImpactedDependencyName)
115+
if name == "" {
116+
return
117+
}
118+
key := dependencyReachabilityKey(name, strings.TrimSpace(v.ImpactedDependencyVersion))
119+
inFile := rowPreferredInputFile(v)
120+
cur := depInfo[key]
121+
if cur == nil {
122+
cur = &depReachInfo{}
123+
depInfo[key] = cur
124+
}
125+
if r > cur.rank {
126+
cur.rank = r
127+
if inFile != "" {
128+
cur.inputFile = inFile
129+
}
130+
} else if r == cur.rank && cur.inputFile == "" && inFile != "" {
131+
cur.inputFile = inFile
132+
}
133+
}
134+
135+
func rowPreferredInputFile(v *formats.VulnerabilityOrViolationRow) string {
136+
for _, comp := range v.Components {
137+
if comp.PreferredLocation != nil {
138+
if f := strings.TrimSpace(comp.PreferredLocation.File); f != "" {
139+
return f
140+
}
141+
}
142+
for _, ev := range comp.Evidences {
143+
if f := strings.TrimSpace(ev.File); f != "" {
144+
return f
145+
}
146+
}
147+
}
148+
return strings.TrimSpace(manifestFileForTechnology(v.Technology))
149+
}
150+
151+
func dependencyReachabilityKey(name, version string) string {
152+
return name + "\x00" + version
153+
}
154+
155+
func gitlabReachabilityRankForRow(v *formats.VulnerabilityOrViolationRow) (reachRank, bool) {
156+
switch rowFinalApplicabilityStatus(v) {
157+
case jasutils.NotScanned:
158+
return reachNone, false
159+
case jasutils.Applicable:
160+
return reachInUse, true
161+
default:
162+
return reachNotFound, true
163+
}
164+
}
165+
166+
func walkComponentSlice(list *[]cyclonedx.Component, depInfo map[string]*depReachInfo) {
167+
if list == nil {
168+
return
169+
}
170+
for i := range *list {
171+
walkComponentTree(&(*list)[i], depInfo)
172+
}
173+
}
174+
175+
func walkComponentTree(c *cyclonedx.Component, depInfo map[string]*depReachInfo) {
176+
if c == nil {
177+
return
178+
}
179+
if idx := strings.Index(c.PackageURL, "?"); idx != -1 {
180+
c.PackageURL = c.PackageURL[:idx]
181+
}
182+
if info := bestReachInfoForComponent(c, depInfo); info != nil && info.rank > reachNone {
183+
if info.inputFile != "" {
184+
c.Properties = cdxutils.AppendProperties(c.Properties, cyclonedx.Property{
185+
Name: gitlabDependencyScanningInputFilePath,
186+
Value: info.inputFile,
187+
})
188+
}
189+
val := gitlabReachabilityNotFound
190+
if info.rank == reachInUse {
191+
val = gitlabReachabilityInUse
192+
}
193+
c.Properties = cdxutils.AppendProperties(c.Properties, cyclonedx.Property{
194+
Name: gitlabDependencyScanningReachability,
195+
Value: val,
196+
})
197+
}
198+
walkComponentSlice(c.Components, depInfo)
199+
}
200+
201+
func bestReachInfoForComponent(c *cyclonedx.Component, depInfo map[string]*depReachInfo) *depReachInfo {
202+
var best *depReachInfo
203+
for _, key := range componentDependencyMatchKeys(c) {
204+
if cur := depInfo[key]; cur != nil && (best == nil || cur.rank > best.rank) {
205+
best = cur
206+
}
207+
}
208+
return best
209+
}
210+
211+
func componentDependencyMatchKeys(c *cyclonedx.Component) []string {
212+
name := strings.TrimSpace(c.Name)
213+
ver := strings.TrimSpace(c.Version)
214+
if name == "" {
215+
return nil
216+
}
217+
var keys []string
218+
if g := strings.TrimSpace(c.Group); g != "" {
219+
keys = append(keys, dependencyReachabilityKey(g+":"+name, ver))
220+
}
221+
keys = append(keys, dependencyReachabilityKey(name, ver))
222+
return keys
223+
}

0 commit comments

Comments
 (0)