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  }