github.com/jfrog/frogbot/v2@v2.21.0/utils/outputwriter/outputcontent.go (about) 1 package outputwriter 2 3 import ( 4 "fmt" 5 "strings" 6 7 "github.com/jfrog/froggit-go/vcsutils" 8 "github.com/jfrog/jfrog-cli-security/formats" 9 xrayutils "github.com/jfrog/jfrog-cli-security/utils" 10 ) 11 12 const ( 13 FrogbotTitlePrefix = "[🐸 Frogbot]" 14 FrogbotRepoUrl = "https://github.com/jfrog/frogbot" 15 FrogbotDocumentationUrl = "https://docs.jfrog-applications.jfrog.io/jfrog-applications/frogbot" 16 ReviewCommentId = "FrogbotReviewComment" 17 18 vulnerableDependenciesTitle = "📦 Vulnerable Dependencies" 19 vulnerableDependenciesResearchDetailsSubTitle = "🔬 Research Details" 20 21 contextualAnalysisTitle = "📦🔍 Contextual Analysis CVE Vulnerability" 22 iacTitle = "🛠️ Infrastructure as Code Vulnerability" 23 sastTitle = "🎯 Static Application Security Testing (SAST) Vulnerability" 24 ) 25 26 var ( 27 CommentGeneratedByFrogbot = MarkAsLink("🐸 JFrog Frogbot", FrogbotDocumentationUrl) 28 jasFeaturesMsgWhenNotEnabled = MarkAsBold("Frogbot") + " also supports " + MarkAsBold("Contextual Analysis, Secret Detection, IaC and SAST Vulnerabilities Scanning") + ". This features are included as part of the " + MarkAsLink("JFrog Advanced Security", "https://jfrog.com/advanced-security") + " package, which isn't enabled on your system." 29 ) 30 31 // Adding markdown prefix to identify Frogbot comment and a footer with the link to the documentation 32 func GetFrogbotCommentBaseDecorator(writer OutputWriter) CommentDecorator { 33 return func(_ int, content string) string { 34 comment := strings.Builder{} 35 comment.WriteString(MarkdownComment(ReviewCommentId)) 36 WriteContent(&comment, content, footer(writer)) 37 return comment.String() 38 } 39 } 40 41 // Adding a banner, custom title and untitled Jas message to the content 42 func GetPRSummaryMainCommentDecorator(issuesExists, isComment bool, writer OutputWriter) CommentDecorator { 43 return func(_ int, content string) string { 44 comment := strings.Builder{} 45 comment.WriteString(writer.Image(getPRSummaryBanner(issuesExists, isComment, writer.VcsProvider()))) 46 customCommentTitle := writer.PullRequestCommentTitle() 47 if customCommentTitle != "" { 48 WriteContent(&comment, writer.MarkAsTitle(MarkAsBold(customCommentTitle), 2)) 49 } 50 if issuesExists { 51 WriteContent(&comment, content) 52 } 53 WriteContent(&comment, untitledForJasMsg(writer)) 54 return comment.String() 55 } 56 } 57 58 func GetPRSummaryContent(contentForComments []string, issuesExists, isComment bool, writer OutputWriter) (comments []string) { 59 return ConvertContentToComments(contentForComments, writer, func(commentCount int, content string) string { 60 if commentCount == 0 { 61 content = GetPRSummaryMainCommentDecorator(issuesExists, isComment, writer)(commentCount, content) 62 } 63 return GetFrogbotCommentBaseDecorator(writer)(commentCount, content) 64 }) 65 } 66 67 func getPRSummaryBanner(issuesExists, isComment bool, provider vcsutils.VcsProvider) ImageSource { 68 if !isComment { 69 return fixCVETitleSrc(provider) 70 } 71 if !issuesExists { 72 return NoIssuesTitleSrc(provider) 73 } 74 return PRSummaryCommentTitleSrc(provider) 75 } 76 77 // TODO: remove this at the next release, it's not used anymore and replaced by adding ReviewCommentId comment to the content 78 func IsFrogbotSummaryComment(writer OutputWriter, content string) bool { 79 client := writer.VcsProvider() 80 return strings.Contains(content, GetBanner(NoIssuesTitleSrc(client))) || 81 strings.Contains(content, GetSimplifiedTitle(NoIssuesTitleSrc(client))) || 82 strings.Contains(content, GetBanner(PRSummaryCommentTitleSrc(client))) || 83 strings.Contains(content, GetSimplifiedTitle(PRSummaryCommentTitleSrc(client))) 84 } 85 86 func NoIssuesTitleSrc(vcsProvider vcsutils.VcsProvider) ImageSource { 87 if vcsProvider == vcsutils.GitLab { 88 return NoVulnerabilityMrBannerSource 89 } 90 return NoVulnerabilityPrBannerSource 91 } 92 93 func PRSummaryCommentTitleSrc(vcsProvider vcsutils.VcsProvider) ImageSource { 94 if vcsProvider == vcsutils.GitLab { 95 return VulnerabilitiesMrBannerSource 96 } 97 return VulnerabilitiesPrBannerSource 98 } 99 100 func fixCVETitleSrc(vcsProvider vcsutils.VcsProvider) ImageSource { 101 if vcsProvider == vcsutils.GitLab { 102 return VulnerabilitiesFixMrBannerSource 103 } 104 return VulnerabilitiesFixPrBannerSource 105 } 106 107 func untitledForJasMsg(writer OutputWriter) string { 108 if writer.AvoidExtraMessages() || writer.IsEntitledForJas() { 109 return "" 110 } 111 return writer.MarkAsDetails("Note:", 0, fmt.Sprintf("%s\n%s", SectionDivider(), writer.MarkInCenter(jasFeaturesMsgWhenNotEnabled))) 112 } 113 114 func footer(writer OutputWriter) string { 115 return fmt.Sprintf("%s\n%s", SectionDivider(), writer.MarkInCenter(CommentGeneratedByFrogbot)) 116 } 117 118 func VulnerabilitiesContent(vulnerabilities []formats.VulnerabilityOrViolationRow, writer OutputWriter) (content []string) { 119 if len(vulnerabilities) == 0 { 120 return []string{} 121 } 122 content = append(content, writer.MarkAsTitle(vulnerableDependenciesTitle, 2)) 123 content = append(content, vulnerabilitiesSummaryContent(vulnerabilities, writer)) 124 content = append(content, vulnerabilityDetailsContent(vulnerabilities, writer)...) 125 return 126 } 127 128 func vulnerabilitiesSummaryContent(vulnerabilities []formats.VulnerabilityOrViolationRow, writer OutputWriter) string { 129 var contentBuilder strings.Builder 130 WriteContent(&contentBuilder, 131 writer.MarkAsTitle("✍️ Summary", 3), 132 writer.MarkInCenter(getVulnerabilitiesSummaryTable(vulnerabilities, writer)), 133 ) 134 return contentBuilder.String() 135 } 136 137 func getVulnerabilitiesSummaryTable(vulnerabilities []formats.VulnerabilityOrViolationRow, writer OutputWriter) string { 138 // Construct table 139 columns := []string{"SEVERITY"} 140 if writer.IsShowingCaColumn() { 141 columns = append(columns, "CONTEXTUAL ANALYSIS") 142 } 143 columns = append(columns, "DIRECT DEPENDENCIES", "IMPACTED DEPENDENCY", "FIXED VERSIONS", "CVES") 144 table := NewMarkdownTable(columns...).SetDelimiter(writer.Separator()) 145 if _, ok := writer.(*SimplifiedOutput); ok { 146 // The values in this cell can be potentially large, since SimplifiedOutput does not support tags, we need to show each value in a separate row. 147 // It means that the first row will show the full details, and the following rows will show only the direct dependency. 148 // It makes it easier to read the table and less crowded with text in a single cell that could be potentially large. 149 table.GetColumnInfo("DIRECT DEPENDENCIES").ColumnType = MultiRowColumn 150 } 151 // Construct rows 152 for _, vulnerability := range vulnerabilities { 153 row := []CellData{{writer.FormattedSeverity(vulnerability.Severity, vulnerability.Applicable)}} 154 if writer.IsShowingCaColumn() { 155 row = append(row, NewCellData(vulnerability.Applicable)) 156 } 157 row = append(row, 158 getDirectDependenciesCellData("%s:%s", vulnerability.Components), 159 NewCellData(fmt.Sprintf("%s %s", vulnerability.ImpactedDependencyName, vulnerability.ImpactedDependencyVersion)), 160 NewCellData(vulnerability.FixedVersions...), 161 getCveIdsCellData(vulnerability.Cves), 162 ) 163 table.AddRowWithCellData(row...) 164 } 165 return table.Build() 166 } 167 168 func getDirectDependenciesCellData(format string, components []formats.ComponentRow) (dependencies CellData) { 169 if len(components) == 0 { 170 return NewCellData() 171 } 172 for _, component := range components { 173 dependencies = append(dependencies, fmt.Sprintf(format, component.Name, component.Version)) 174 } 175 return 176 } 177 178 func getCveIdsCellData(cveRows []formats.CveRow) (ids CellData) { 179 if len(cveRows) == 0 { 180 return NewCellData() 181 } 182 for _, cve := range cveRows { 183 ids = append(ids, cve.Id) 184 } 185 return 186 } 187 188 type vulnerabilityOrViolationDetails struct { 189 details string 190 title string 191 dependencyName string 192 dependencyVersion string 193 } 194 195 func vulnerabilityDetailsContent(vulnerabilities []formats.VulnerabilityOrViolationRow, writer OutputWriter) (content []string) { 196 vulnerabilitiesWithDetails := getVulnerabilityWithDetails(vulnerabilities) 197 if len(vulnerabilitiesWithDetails) == 0 { 198 return 199 } 200 // Prepare content for each vulnerability details 201 for i := range vulnerabilitiesWithDetails { 202 if len(vulnerabilitiesWithDetails) == 1 { 203 content = append(content, vulnerabilitiesWithDetails[i].details) 204 } else { 205 content = append(content, writer.MarkAsDetails( 206 fmt.Sprintf(`%s %s %s`, vulnerabilitiesWithDetails[i].title, 207 vulnerabilitiesWithDetails[i].dependencyName, 208 vulnerabilitiesWithDetails[i].dependencyVersion), 209 4, vulnerabilitiesWithDetails[i].details, 210 )) 211 } 212 } 213 // Split content if it exceeds the size limit and decorate it with title 214 return ConvertContentToComments(content, writer, func(commentCount int, detailsContent string) string { 215 contentBuilder := strings.Builder{} 216 WriteContent(&contentBuilder, writer.MarkAsTitle(vulnerableDependenciesResearchDetailsSubTitle, 3)) 217 WriteContent(&contentBuilder, detailsContent) 218 return contentBuilder.String() 219 }) 220 } 221 222 func getVulnerabilityWithDetails(vulnerabilities []formats.VulnerabilityOrViolationRow) (vulnerabilitiesWithDetails []vulnerabilityOrViolationDetails) { 223 for i := range vulnerabilities { 224 vulDescriptionContent := createVulnerabilityResearchDescription(&vulnerabilities[i]) 225 if vulDescriptionContent == "" { 226 // No content 227 continue 228 } 229 vulnerabilitiesWithDetails = append(vulnerabilitiesWithDetails, vulnerabilityOrViolationDetails{ 230 details: vulDescriptionContent, 231 title: getVulnerabilityDescriptionIdentifier(vulnerabilities[i].Cves, vulnerabilities[i].IssueId), 232 dependencyName: vulnerabilities[i].ImpactedDependencyName, 233 dependencyVersion: vulnerabilities[i].ImpactedDependencyVersion, 234 }) 235 } 236 return 237 } 238 239 func createVulnerabilityResearchDescription(vulnerability *formats.VulnerabilityOrViolationRow) string { 240 var descriptionBuilder strings.Builder 241 vulnResearch := vulnerability.JfrogResearchInformation 242 if vulnResearch == nil { 243 vulnResearch = &formats.JfrogResearchInformation{Details: vulnerability.Summary} 244 } else if vulnResearch.Details == "" { 245 vulnResearch.Details = vulnerability.Summary 246 } 247 248 if vulnResearch.Details != "" { 249 WriteContent(&descriptionBuilder, MarkAsBold("Description:"), vulnResearch.Details) 250 } 251 if vulnResearch.Remediation != "" { 252 if vulnResearch.Details != "" { 253 WriteNewLine(&descriptionBuilder) 254 } 255 WriteContent(&descriptionBuilder, MarkAsBold("Remediation:"), vulnResearch.Remediation) 256 } 257 return descriptionBuilder.String() 258 } 259 260 func getVulnerabilityDescriptionIdentifier(cveRows []formats.CveRow, xrayId string) string { 261 identifier := xrayutils.GetIssueIdentifier(cveRows, xrayId) 262 if identifier == "" { 263 return "" 264 } 265 return fmt.Sprintf("[ %s ]", identifier) 266 } 267 268 func LicensesContent(licenses []formats.LicenseRow, writer OutputWriter) string { 269 if len(licenses) == 0 { 270 return "" 271 } 272 // Title 273 var contentBuilder strings.Builder 274 WriteContent(&contentBuilder, writer.MarkAsTitle("⚖️ Violated Licenses", 2)) 275 // Content 276 table := NewMarkdownTable("SEVERITY", "LICENSE", "DIRECT DEPENDENCIES", "IMPACTED DEPENDENCY").SetDelimiter(writer.Separator()) 277 for _, license := range licenses { 278 table.AddRowWithCellData( 279 NewCellData(license.Severity), 280 NewCellData(license.LicenseKey), 281 getDirectDependenciesCellData("%s %s", license.Components), 282 NewCellData(fmt.Sprintf("%s %s", license.ImpactedDependencyName, license.ImpactedDependencyVersion)), 283 ) 284 } 285 WriteContent(&contentBuilder, writer.MarkInCenter(table.Build())) 286 return contentBuilder.String() 287 } 288 289 // For review comment Frogbot creates on Scan PR 290 func GenerateReviewCommentContent(content string, writer OutputWriter) string { 291 var contentBuilder strings.Builder 292 contentBuilder.WriteString(MarkdownComment(ReviewCommentId)) 293 customCommentTitle := writer.PullRequestCommentTitle() 294 if customCommentTitle != "" { 295 WriteContent(&contentBuilder, writer.MarkAsTitle(MarkAsBold(customCommentTitle), 2)) 296 } 297 WriteContent(&contentBuilder, content, footer(writer)) 298 return contentBuilder.String() 299 } 300 301 // When can't create review comment, create a fallback comment by adding the location description to the content as a prefix 302 func GetFallbackReviewCommentContent(content string, location formats.Location, writer OutputWriter) string { 303 var contentBuilder strings.Builder 304 contentBuilder.WriteString(MarkdownComment(ReviewCommentId)) 305 WriteContent(&contentBuilder, getFallbackCommentLocationDescription(location), content, footer(writer)) 306 return contentBuilder.String() 307 } 308 309 func IsFrogbotComment(content string) bool { 310 return strings.Contains(content, ReviewCommentId) 311 } 312 313 func getFallbackCommentLocationDescription(location formats.Location) string { 314 return fmt.Sprintf("%s\nat %s (line %d)", MarkAsCodeSnippet(location.Snippet), MarkAsQuote(location.File), location.StartLine) 315 } 316 317 func GetApplicabilityDescriptionTable(severity, cve, impactedDependency, finding string, writer OutputWriter) string { 318 table := NewMarkdownTable("Severity", "Impacted Dependency", "Finding", "CVE").AddRow(writer.FormattedSeverity(severity, "Applicable"), impactedDependency, finding, cve) 319 return table.Build() 320 } 321 322 func ApplicableCveReviewContent(severity, finding, fullDetails, cve, cveDetails, impactedDependency, remediation string, writer OutputWriter) string { 323 var contentBuilder strings.Builder 324 WriteContent(&contentBuilder, 325 writer.MarkAsTitle(contextualAnalysisTitle, 2), 326 writer.MarkInCenter(GetApplicabilityDescriptionTable(severity, cve, impactedDependency, finding, writer)), 327 writer.MarkAsDetails("Description", 3, fullDetails), 328 writer.MarkAsDetails("CVE details", 3, cveDetails), 329 ) 330 331 if len(remediation) > 0 { 332 WriteContent(&contentBuilder, writer.MarkAsDetails("Remediation", 3, remediation)) 333 } 334 return contentBuilder.String() 335 } 336 337 func getJasDescriptionTable(severity, finding string, writer OutputWriter) string { 338 return NewMarkdownTable("Severity", "Finding").AddRow(writer.FormattedSeverity(severity, "Applicable"), finding).Build() 339 } 340 341 func IacReviewContent(severity, finding, fullDetails string, writer OutputWriter) string { 342 var contentBuilder strings.Builder 343 WriteContent(&contentBuilder, 344 writer.MarkAsTitle(iacTitle, 2), 345 writer.MarkInCenter(getJasDescriptionTable(severity, finding, writer)), 346 writer.MarkAsDetails("Full description", 3, fullDetails), 347 ) 348 return contentBuilder.String() 349 } 350 351 func SastReviewContent(severity, finding, fullDetails string, codeFlows [][]formats.Location, writer OutputWriter) string { 352 var contentBuilder strings.Builder 353 WriteContent(&contentBuilder, 354 writer.MarkAsTitle(sastTitle, 2), 355 writer.MarkInCenter(getJasDescriptionTable(severity, finding, writer)), 356 writer.MarkAsDetails("Full description", 3, fullDetails), 357 ) 358 359 if len(codeFlows) > 0 { 360 WriteContent(&contentBuilder, writer.MarkAsDetails("Code Flows", 3, sastCodeFlowsReviewContent(codeFlows, writer))) 361 } 362 return contentBuilder.String() 363 } 364 365 func sastCodeFlowsReviewContent(codeFlows [][]formats.Location, writer OutputWriter) string { 366 var contentBuilder strings.Builder 367 for _, flow := range codeFlows { 368 WriteContent(&contentBuilder, writer.MarkAsDetails("Vulnerable data flow analysis result", 4, sastDataFlowLocationsReviewContent(flow))) 369 } 370 return contentBuilder.String() 371 } 372 373 func sastDataFlowLocationsReviewContent(flow []formats.Location) string { 374 var contentBuilder strings.Builder 375 for _, location := range flow { 376 WriteContent(&contentBuilder, fmt.Sprintf("%s %s (at %s line %d)\n", "↘️", MarkAsQuote(location.Snippet), location.File, location.StartLine)) 377 } 378 return contentBuilder.String() 379 }