diff --git a/go.mod b/go.mod index 728fae4e4..f22ca2ae5 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/c-bata/go-prompt v0.2.6 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect diff --git a/go.sum b/go.sum index 01c9dfc9a..feedf4c86 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 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 +}