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= diff --git a/scanrepository/scanrepository.go b/scanrepository/scanrepository.go index 379c45d29..85f9c4c0d 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" @@ -153,9 +154,10 @@ func (sr *ScanRepositoryCmd) scanAndFixBranch(repository *utils.Repository) (tot }() totalFindings = getTotalFindingsFromScanResults(scanResults) sr.uploadResultsToGithubDashboardsIfNeeded(repository, scanResults) + 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 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 } @@ -185,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/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/cyclonedx_gitlab_reachability.go b/utils/gitlabreport/cyclonedx_gitlab_reachability.go new file mode 100644 index 000000000..dc4c6417f --- /dev/null +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability.go @@ -0,0 +1,223 @@ +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: 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 + } + 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]) + } + + // 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 f := uniqueInputFileFromDepInfo(depInfo); f != "" { + bom.Metadata.Properties = cdxutils.AppendProperties(bom.Metadata.Properties, cyclonedx.Property{ + Name: gitlabDependencyScanningInputFilePath, + Value: f, + }) + } + + if bom.Metadata.Component != nil { + walkComponentTree(bom.Metadata.Component, depInfo) + } + walkComponentSlice(bom.Components, depInfo) +} + +type depReachInfo struct { + rank reachRank + inputFile 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 + } + if f := strings.TrimSpace(info.inputFile); f != "" { + seen[f] = struct{}{} + } + } + if len(seen) == 1 { + for f := range seen { + return f + } + } + return "" +} + +func mergeRowReachability(depInfo map[string]*depReachInfo, v *formats.VulnerabilityOrViolationRow) { + r, ok := gitlabReachabilityRankForRow(v) + if !ok { + return + } + name := strings.TrimSpace(v.ImpactedDependencyName) + if name == "" { + return + } + key := dependencyReachabilityKey(name, strings.TrimSpace(v.ImpactedDependencyVersion)) + inFile := rowPreferredInputFile(v) + 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 + } +} + +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 dependencyReachabilityKey(name, version string) string { + return name + "\x00" + version +} + +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 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{ + 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..53718c445 --- /dev/null +++ b/utils/gitlabreport/cyclonedx_gitlab_reachability_test.go @@ -0,0 +1,111 @@ +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) +} + +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 new file mode 100644 index 000000000..44dbe4553 --- /dev/null +++ b/utils/gitlabreport/gitlabreport.go @@ -0,0 +1,509 @@ +package gitlabreport + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "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" + "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"` + EndTime string `json:"end_time"` + Status string `json:"status"` + Type string `json:"type"` +} + +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"` + 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"` + Value string `json:"value"` +} + +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: true, + 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...) + + unique := make([]formats.VulnerabilityOrViolationRow, 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{}{} + unique = append(unique, v) + } + sortVulnerabilityRowsForGitLab(unique) + + reports := make([]VulnerabilityReport, 0, len(unique)) + for i := range unique { + report := vulnerabilityToReport(&unique[i]) + 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) + manifestPath := rowPreferredInputFile(v) + location := Location{ + File: manifestPath, + Dependency: Dependency{ + Package: Package{Name: v.ImpactedDependencyName}, + Version: v.ImpactedDependencyVersion, + }, + } + severity := normalizeSeverity(getSeverity(v)) + name := buildVulnerabilityNameWithContextualAnalysis(v) + desc := strings.TrimSpace(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 += 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.Id, + 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: v.IssueId, + Value: v.IssueId, + }) + } + if len(ids) == 0 { + issue := strings.TrimSpace(v.IssueId) + if issue != "" && strings.HasPrefix(strings.ToUpper(issue), "CVE-") { + 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) +} + +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) + } + } + 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 "" +} + +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 + } + 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 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 "" + } + st := rowFinalApplicabilityStatus(v) + display := st.String() + if st == jasutils.NotScanned || display == "" { + display = jasutils.NotCovered.String() + } + return fmt.Sprintf("%s (%s)", base, display) +} + +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 + } +} + +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) +} + +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": + 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.Pnpm: + return "pnpm-lock.yaml" + 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" + } +} + +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/gitlabreport/gitlabreport_test.go b/utils/gitlabreport/gitlabreport_test.go new file mode 100644 index 000000000..e64f4da7f --- /dev/null +++ b/utils/gitlabreport/gitlabreport_test.go @@ -0,0 +1,381 @@ +package gitlabreport + +import ( + "encoding/json" + "errors" + "fmt" + "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/jasutils" + "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 + wantName string + wantDescription 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", Cwe: []string{"CWE-502", "502"}}}, // duplicate normalized → one CWE id + Summary: "Test summary", + Technology: techutils.Npm, + 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 name", + 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", 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, + }, + wantName: "CVE-2023-1 (Applicable)", + wantDescription: "Details here", + wantSeverity: "Low", + wantManifest: "package-lock.json", + identifierTypes: []string{"cve", "cve", "xray", "cwe", "cwe"}, + }, + { + 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"}, + }, + Cves: []formats.CveRow{{Cwe: []string{"20"}}}, + Technology: techutils.Go, + }, + wantName: "XRAY-100 (Not Covered)", + wantDescription: "", + wantSeverity: "Low", + wantManifest: "go.sum", + identifierTypes: []string{"xray", "cwe"}, + }, + { + name: "fallback identifier when no CVE or issue id", + row: formats.VulnerabilityOrViolationRow{ + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "orphan", + ImpactedDependencyVersion: "0.0.1", + }, + Cves: []formats.CveRow{{Cwe: []string{"CWE-787"}}}, + Technology: techutils.Maven, + }, + wantName: "", + wantDescription: "", + wantSeverity: "Unknown", + wantManifest: "pom.xml", + identifierTypes: []string{"other", "cwe"}, + }, + { + name: "CVE from issueId when cves slice has no id", + row: formats.VulnerabilityOrViolationRow{ + IssueId: "CVE-2020-9999", + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "dep", + ImpactedDependencyVersion: "1.0.0", + SeverityDetails: formats.SeverityDetails{Severity: "high"}, + }, + Cves: []formats.CveRow{{Cwe: []string{"22"}}}, + Summary: "from issue id only", + Technology: techutils.Maven, + }, + wantName: "CVE-2020-9999 (Not Covered)", + wantDescription: "from issue id only", + wantSeverity: "High", + wantManifest: "pom.xml", + identifierTypes: []string{"cve", "cwe"}, + }, + } + 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.wantDescription, got.Description) + assert.Equal(t, tt.wantSeverity, got.Severity) + assert.Equal(t, tt.wantManifest, got.Location.File) + 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) + 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", Cwe: []string{"1004"}}}, + 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"`) + 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"`) +} + +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 { + 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 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{}) + 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.go b/utils/utils.go index 1876d2c6b..c3a16b2c5 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,10 +8,12 @@ import ( "fmt" "net/http" "os" + "path/filepath" "regexp" "sort" "strings" "sync" + "time" "github.com/jfrog/froggit-go/vcsclient" "github.com/jfrog/gofrog/version" @@ -29,6 +31,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 +52,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 +463,48 @@ func CreateErrorIfFailUponScannerErrorEnabled(fail bool, messageForLog string, e } return err } + +func WriteScanResultsToGitlabDir(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: true, + IncludeSbom: true, + }).ConvertToCycloneDx(scanResults) + if err != nil { + return fmt.Errorf("convert to CycloneDX: %w", err) + } + bom := fullBom.BOM + gitlabreport.EnrichCycloneDXBOMForGitLabReachability(&bom, scanResults) + path := filepath.Join(outputDir, cyclonedxOutputFilename) + 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)) + return nil +} diff --git a/utils/utils_test.go b/utils/utils_test.go index 961f87d68..8c3f0a952 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 TestWriteScanResultsToGitlabDir(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 := WriteScanResultsToGitlabDir(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) + } + }) + } +}