diff --git a/pkg/reporting/exporters/markdown/util/markdown_formatter.go b/pkg/reporting/exporters/markdown/util/markdown_formatter.go index 7f84652b0..7ac49a4e9 100644 --- a/pkg/reporting/exporters/markdown/util/markdown_formatter.go +++ b/pkg/reporting/exporters/markdown/util/markdown_formatter.go @@ -28,6 +28,10 @@ func (markdownFormatter MarkdownFormatter) CreateHorizontalLine() string { return CreateHorizontalLine() } +func (markdownFormatter MarkdownFormatter) FormatLineBreaks(text string) string { + return strings.ReplaceAll(text, "\n", "
") +} + // escapeCodeBlockMarkdown only escapes the bare minimum characters needed // for code blocks and other sections where readability is important // diff --git a/pkg/reporting/format/format.go b/pkg/reporting/format/format.go index f801add34..0ee1747c9 100644 --- a/pkg/reporting/format/format.go +++ b/pkg/reporting/format/format.go @@ -6,4 +6,5 @@ type ResultFormatter interface { CreateTable(headers []string, rows [][]string) (string, error) CreateLink(title string, url string) string CreateHorizontalLine() string + FormatLineBreaks(text string) string } diff --git a/pkg/reporting/format/format_utils.go b/pkg/reporting/format/format_utils.go index 92976d30f..ebd66fd10 100644 --- a/pkg/reporting/format/format_utils.go +++ b/pkg/reporting/format/format_utils.go @@ -9,7 +9,6 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" "github.com/projectdiscovery/nuclei/v3/pkg/model" "github.com/projectdiscovery/nuclei/v3/pkg/output" - "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util" "github.com/projectdiscovery/nuclei/v3/pkg/types" "github.com/projectdiscovery/nuclei/v3/pkg/utils" unitutils "github.com/projectdiscovery/utils/unit" @@ -171,20 +170,20 @@ func CreateTemplateInfoTable(templateInfo *model.Info, formatter ResultFormatter } if !utils.IsBlank(templateInfo.Description) { - rows = append(rows, []string{"Description", lineBreakToHTML(templateInfo.Description)}) + rows = append(rows, []string{"Description", formatter.FormatLineBreaks(templateInfo.Description)}) } if !utils.IsBlank(templateInfo.Remediation) { - rows = append(rows, []string{"Remediation", lineBreakToHTML(templateInfo.Remediation)}) + rows = append(rows, []string{"Remediation", formatter.FormatLineBreaks(templateInfo.Remediation)}) } classification := templateInfo.Classification if classification != nil { if classification.CVSSMetrics != "" { - rows = append(rows, []string{"CVSS-Metrics", generateCVSSMetricsFromClassification(classification)}) + rows = append(rows, []string{"CVSS-Metrics", generateCVSSMetricsFromClassification(classification, formatter)}) } - rows = append(rows, generateCVECWEIDLinksFromClassification(classification)...) + rows = append(rows, generateCVECWEIDLinksFromClassification(classification, formatter)...) rows = append(rows, []string{"CVSS-Score", strconv.FormatFloat(classification.CVSSScore, 'f', 2, 64)}) } @@ -202,7 +201,7 @@ func CreateTemplateInfoTable(templateInfo *model.Info, formatter ResultFormatter return table } -func generateCVSSMetricsFromClassification(classification *model.Classification) string { +func generateCVSSMetricsFromClassification(classification *model.Classification, formatter ResultFormatter) string { var cvssLinkPrefix string if strings.Contains(classification.CVSSMetrics, "CVSS:3.0") { cvssLinkPrefix = "https://www.first.org/cvss/calculator/3.0#" @@ -213,11 +212,11 @@ func generateCVSSMetricsFromClassification(classification *model.Classification) if cvssLinkPrefix == "" { return classification.CVSSMetrics } else { - return util.CreateLink(classification.CVSSMetrics, cvssLinkPrefix+classification.CVSSMetrics) + return formatter.CreateLink(classification.CVSSMetrics, cvssLinkPrefix+classification.CVSSMetrics) } } -func generateCVECWEIDLinksFromClassification(classification *model.Classification) [][]string { +func generateCVECWEIDLinksFromClassification(classification *model.Classification, formatter ResultFormatter) [][]string { cwes := classification.CWEID.ToSlice() cweIDs := make([]string, 0, len(cwes)) @@ -226,7 +225,7 @@ func generateCVECWEIDLinksFromClassification(classification *model.Classificatio if len(parts) != 2 { continue } - cweIDs = append(cweIDs, util.CreateLink(strings.ToUpper(value), fmt.Sprintf("https://cwe.mitre.org/data/definitions/%s.html", parts[1]))) + cweIDs = append(cweIDs, formatter.CreateLink(strings.ToUpper(value), fmt.Sprintf("https://cwe.mitre.org/data/definitions/%s.html", parts[1]))) } var rows [][]string @@ -238,7 +237,7 @@ func generateCVECWEIDLinksFromClassification(classification *model.Classificatio cves := classification.CVEID.ToSlice() cveIDs := make([]string, 0, len(cves)) for _, value := range cves { - cveIDs = append(cveIDs, util.CreateLink(strings.ToUpper(value), fmt.Sprintf("https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s", value))) + cveIDs = append(cveIDs, formatter.CreateLink(strings.ToUpper(value), fmt.Sprintf("https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s", value))) } if len(cveIDs) > 0 { rows = append(rows, []string{"CVE-ID", strings.Join(cveIDs, ",")}) diff --git a/pkg/reporting/trackers/jira/jira.go b/pkg/reporting/trackers/jira/jira.go index bfb518daa..8e635eddb 100644 --- a/pkg/reporting/trackers/jira/jira.go +++ b/pkg/reporting/trackers/jira/jira.go @@ -35,17 +35,57 @@ func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ } func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) { - table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows) - if err != nil { - return "", err + if len(headers) == 0 { + return "", fmt.Errorf("no headers provided") } - tableRows := strings.Split(table, "\n") - tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...) - return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil + + var builder strings.Builder + + // Create header row with leading and trailing pipes + builder.WriteString("| ") + builder.WriteString(strings.Join(headers, " | ")) + builder.WriteString(" |") + builder.WriteString("\n") + + // Create separator row with leading and trailing pipes + separators := make([]string, len(headers)) + for i := range separators { + separators[i] = "-----------" + } + builder.WriteString("|") + builder.WriteString(strings.Join(separators, "|")) + builder.WriteString("|") + builder.WriteString("\n") + + // Create data rows with leading and trailing pipes + for _, row := range rows { + builder.WriteString("| ") + if len(row) < len(headers) { + extendedRow := make([]string, len(headers)) + copy(extendedRow, row) + builder.WriteString(strings.Join(extendedRow, " | ")) + } else if len(row) > len(headers) { + builder.WriteString(strings.Join(row[:len(headers)], " | ")) + } else { + builder.WriteString(strings.Join(row, " | ")) + } + builder.WriteString(" |") + builder.WriteString("\n") + } + + return builder.String(), nil +} + +func (jiraFormatter *Formatter) CreateHorizontalLine() string { + return "----\n" +} + +func (jiraFormatter *Formatter) FormatLineBreaks(text string) string { + return strings.ReplaceAll(text, "\n", "\\\\") } func (jiraFormatter *Formatter) CreateLink(title string, url string) string { - return fmt.Sprintf("[%s|%s]", title, url) + return fmt.Sprintf("[%s](%s)", title, url) } // Integration is a client for an issue tracker integration diff --git a/pkg/reporting/trackers/jira/jira_test.go b/pkg/reporting/trackers/jira/jira_test.go index d725a97b2..e1a327e42 100644 --- a/pkg/reporting/trackers/jira/jira_test.go +++ b/pkg/reporting/trackers/jira/jira_test.go @@ -3,10 +3,13 @@ package jira import ( "strings" "testing" + "time" "github.com/projectdiscovery/nuclei/v3/pkg/model" "github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity" + "github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice" "github.com/projectdiscovery/nuclei/v3/pkg/output" + "github.com/projectdiscovery/nuclei/v3/pkg/reporting/format" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters" "github.com/stretchr/testify/require" ) @@ -14,7 +17,18 @@ import ( func TestLinkCreation(t *testing.T) { jiraIntegration := &Integration{} link := jiraIntegration.CreateLink("ProjectDiscovery", "https://projectdiscovery.io") - require.Equal(t, "[ProjectDiscovery|https://projectdiscovery.io]", link) + require.Equal(t, "[ProjectDiscovery](https://projectdiscovery.io)", link) +} + +func TestLinkCreationWithSpecialCharacters(t *testing.T) { + jiraIntegration := &Integration{} + + link := jiraIntegration.CreateLink("Nuclei [v3.4.4]", "https://github.com/projectdiscovery/nuclei") + expected := "[Nuclei [v3.4.4]](https://github.com/projectdiscovery/nuclei)" + require.Equal(t, expected, link) + + require.NotContains(t, link, "%5D") + require.NotContains(t, link, "%5B") } func TestHorizontalLineCreation(t *testing.T) { @@ -34,6 +48,7 @@ func TestTableCreation(t *testing.T) { require.Nil(t, err) expected := `| key | value | +|-----------|-----------| | a | b | | c | | | d | e | @@ -41,6 +56,129 @@ func TestTableCreation(t *testing.T) { require.Equal(t, expected, table) } +func TestTableCreationWithComplexData(t *testing.T) { + jiraIntegration := &Integration{} + + table, err := jiraIntegration.CreateTable([]string{"Key", "Value"}, [][]string{ + {"Name", "GraphQL CSRF / GET method"}, + {"Authors", "Dolev Farhi"}, + {"Tags", "graphql, misconfig"}, + {"Severity", "info"}, + }) + + require.Nil(t, err) + + require.Contains(t, table, "| Key | Value |") + require.Contains(t, table, "|-----------|-----------|") + require.Contains(t, table, "| Name | GraphQL CSRF / GET method |") + + require.Contains(t, table, "GraphQL CSRF") + require.NotContains(t, table, "| ame |") + require.NotContains(t, table, "| uthors |") +} + +func TestFormatLineBreaks(t *testing.T) { + jiraIntegration := &Integration{} + + input := "Line 1\nLine 2\nLine 3" + result := jiraIntegration.FormatLineBreaks(input) + expected := "Line 1\\\\Line 2\\\\Line 3" + + require.Equal(t, expected, result) + + require.NotContains(t, result, "
") +} + +func TestFormatLineBreaksWithMultipleBreaks(t *testing.T) { + jiraIntegration := &Integration{} + + input := "Cross Site Request Forgery happens when an external website gains ability to make API calls impersonating an user.\nAllowing API calls through GET requests can lead to CSRF attacks." + result := jiraIntegration.FormatLineBreaks(input) + expected := "Cross Site Request Forgery happens when an external website gains ability to make API calls impersonating an user.\\\\Allowing API calls through GET requests can lead to CSRF attacks." + + require.Equal(t, expected, result) +} + +func TestCompleteReportGeneration(t *testing.T) { + jiraIntegration := &Integration{} + + event := &output.ResultEvent{ + TemplateID: "graphql-get-method", + Info: model.Info{ + Name: "GraphQL CSRF / GET method", + Authors: stringslice.StringSlice{Value: []string{"Dolev Farhi"}}, + Tags: stringslice.StringSlice{Value: []string{"graphql", "misconfig"}}, + SeverityHolder: severity.Holder{Severity: severity.Info}, + Description: "Cross Site Request Forgery happens when an external website gains ability to make API calls impersonating an user.\nAllowing API calls through GET requests can lead to CSRF attacks.", + Reference: stringslice.NewRawStringSlice([]string{ + "https://graphql.org/learn/serving-over-http/#get-request", + "https://github.com/dolevf/Damn-Vulnerable-GraphQL-Application", + }), + }, + Host: "example.com", + Matched: "example.com/graphql", + Timestamp: time.Date(2025, 5, 31, 12, 0, 0, 0, time.UTC), + Type: "http", + } + + description := format.CreateReportDescription(event, jiraIntegration, false) + + require.Contains(t, description, "*Details*: *graphql-get-method* matched at example.com") + require.Contains(t, description, "*Protocol*: HTTP") + require.Contains(t, description, "*Template Information*") + + require.Contains(t, description, "| Key | Value |") + require.Contains(t, description, "|-----------|-----------|") + require.Contains(t, description, "| Name | GraphQL CSRF / GET method |") + + require.Contains(t, description, "impersonating an user.\\\\Allowing API calls") + require.NotContains(t, description, "
") + + require.Contains(t, description, "[Nuclei") + require.Contains(t, description, "](https://github.com/projectdiscovery/nuclei)") + + require.NotContains(t, description, "%5D") + require.NotContains(t, description, "%5B") + + require.Contains(t, description, "References:") + require.Contains(t, description, "https://graphql.org/learn/serving-over-http/#get-request") +} + +func TestReportWithCVELinks(t *testing.T) { + jiraIntegration := &Integration{} + + event := &output.ResultEvent{ + TemplateID: "test-cve", + Info: model.Info{ + Name: "Test CVE Template", + Authors: stringslice.StringSlice{Value: []string{"test-author"}}, + Tags: stringslice.StringSlice{Value: []string{"cve", "test"}}, + SeverityHolder: severity.Holder{Severity: severity.High}, + Description: "Test template with CVE links", + Classification: &model.Classification{ + CVEID: stringslice.StringSlice{Value: []string{"CVE-2021-44228"}}, + CWEID: stringslice.StringSlice{Value: []string{"CWE-502"}}, + CVSSMetrics: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + CVSSScore: 10.0, + }, + }, + Host: "example.com", + Matched: "example.com/test", + Timestamp: time.Date(2025, 5, 31, 12, 0, 0, 0, time.UTC), + Type: "http", + } + + description := format.CreateReportDescription(event, jiraIntegration, false) + + require.Contains(t, description, "[CVE-2021-44228](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-44228)") + require.Contains(t, description, "[CWE-502](https://cwe.mitre.org/data/definitions/502.html)") + require.Contains(t, description, "[CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H](https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)") + + require.NotContains(t, description, "|https://") + + require.NotContains(t, description, "%5D") +} + func Test_ShouldFilter_Tracker(t *testing.T) { jiraIntegration := &Integration{ options: &Options{AllowList: &filters.Filter{