fix(reporting): Markdown and Jira exporter fixes (#3849)

* fix(reporting): Markdown and Jira exporter fixes

* removed the code duplication between the Markdown and Jira exporter
* markdown requires at least 3 dashes in the cells to separate headers from contents in a table
* fixed the Jira link creation in the description
* Jira requires at least 4 dashes for a horizontal line
* added tests
* Jira doesn't use dashed separators between table headers and contents

* fix(reporting): Markdown and Jira exporter fixes

* satisfying the linter

* minor syntax changes

---------

Co-authored-by: Mzack9999 <mzack9999@protonmail.com>
dev
forgedhallpass 2023-06-22 14:27:32 +03:00 committed by GitHub
parent 4d8c4b7024
commit 442fc0f060
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 540 additions and 410 deletions

View File

@ -7,11 +7,13 @@ import (
"strings"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/format"
stringsutil "github.com/projectdiscovery/utils/strings"
)
const indexFileName = "index.md"
const extension = ".md"
type Exporter struct {
directory string
@ -37,9 +39,7 @@ func New(options *Options) (*Exporter, error) {
_ = os.MkdirAll(directory, 0755)
// index generation header
dataHeader := "" +
"|Hostname/IP|Finding|Severity|\n" +
"|-|-|-|\n"
dataHeader := util.CreateTableHeader("Hostname/IP", "Finding", "Severity")
err := os.WriteFile(filepath.Join(directory, indexFileName), []byte(dataHeader), 0644)
if err != nil {
@ -51,9 +51,34 @@ func New(options *Options) (*Exporter, error) {
// Export exports a passed result event to markdown
func (exporter *Exporter) Export(event *output.ResultEvent) error {
summary := format.Summary(event)
description := format.MarkdownDescription(event)
// index file generation
file, err := os.OpenFile(filepath.Join(exporter.directory, indexFileName), os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
filename := createFileName(event)
host := util.CreateLink(event.Host, filename)
finding := event.TemplateID + " " + event.MatcherName
severity := event.Info.SeverityHolder.Severity.String()
_, err = file.WriteString(util.CreateTableRow(host, finding, severity))
if err != nil {
return err
}
dataBuilder := &bytes.Buffer{}
dataBuilder.WriteString(util.CreateHeading3(format.Summary(event)))
dataBuilder.WriteString("\n")
dataBuilder.WriteString(util.CreateHorizontalLine())
dataBuilder.WriteString(format.CreateReportDescription(event, util.MarkdownFormatter{}))
data := dataBuilder.Bytes()
return os.WriteFile(filepath.Join(exporter.directory, filename), data, 0644)
}
func createFileName(event *output.ResultEvent) string {
filenameBuilder := &strings.Builder{}
filenameBuilder.WriteString(event.TemplateID)
filenameBuilder.WriteString("-")
@ -69,29 +94,8 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
filenameBuilder.WriteRune('-')
filenameBuilder.WriteString(event.MatcherName)
}
filenameBuilder.WriteString(".md")
finalFilename := sanitizeFilename(filenameBuilder.String())
dataBuilder := &bytes.Buffer{}
dataBuilder.WriteString("### ")
dataBuilder.WriteString(summary)
dataBuilder.WriteString("\n---\n")
dataBuilder.WriteString(description)
data := dataBuilder.Bytes()
// index generation
file, err := os.OpenFile(filepath.Join(exporter.directory, indexFileName), os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString("|[" + event.Host + "](" + finalFilename + ")" + "|" + event.TemplateID + " " + event.MatcherName + "|" + event.Info.SeverityHolder.Severity.String() + "|\n")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(exporter.directory, finalFilename), data, 0644)
filenameBuilder.WriteString(extension)
return sanitizeFilename(filenameBuilder.String())
}
// Close closes the exporter after operation

View File

@ -0,0 +1,27 @@
package util
import (
"fmt"
)
type MarkdownFormatter struct{}
func (markdownFormatter MarkdownFormatter) MakeBold(text string) string {
return MakeBold(text)
}
func (markdownFormatter MarkdownFormatter) CreateCodeBlock(title string, content string, language string) string {
return fmt.Sprintf("\n%s\n```%s\n%s\n```\n", markdownFormatter.MakeBold(title), language, content)
}
func (markdownFormatter MarkdownFormatter) CreateTable(headers []string, rows [][]string) (string, error) {
return CreateTable(headers, rows)
}
func (markdownFormatter MarkdownFormatter) CreateLink(title string, url string) string {
return CreateLink(title, url)
}
func (markdownFormatter MarkdownFormatter) CreateHorizontalLine() string {
return CreateHorizontalLine()
}

View File

@ -0,0 +1,65 @@
package util
import (
"bytes"
"fmt"
"strings"
errorutil "github.com/projectdiscovery/utils/errors"
)
func CreateLink(title string, url string) string {
return fmt.Sprintf("[%s](%s)", title, url)
}
func MakeBold(text string) string {
return "**" + text + "**"
}
func CreateTable(headers []string, rows [][]string) (string, error) {
builder := &bytes.Buffer{}
headerSize := len(headers)
if headers == nil || headerSize == 0 {
return "", errorutil.New("No headers provided")
}
builder.WriteString(CreateTableHeader(headers...))
for _, row := range rows {
rowSize := len(row)
if rowSize == headerSize {
builder.WriteString(CreateTableRow(row...))
} else if rowSize < headerSize {
extendedRows := make([]string, headerSize)
copy(extendedRows, row)
builder.WriteString(CreateTableRow(extendedRows...))
} else {
return "", errorutil.New("Too many columns for the given headers")
}
}
return builder.String(), nil
}
func CreateTableHeader(headers ...string) string {
headerSize := len(headers)
if headers == nil || headerSize == 0 {
return ""
}
return CreateTableRow(headers...) +
"|" + strings.Repeat(" --- |", headerSize) + "\n"
}
func CreateTableRow(elements ...string) string {
return fmt.Sprintf("| %s |\n", strings.Join(elements, " | "))
}
func CreateHeading3(text string) string {
return "### " + text + "\n"
}
func CreateHorizontalLine() string {
// for regular markdown 3 dashes are enough, but for Jira the minimum is 4
return "----\n"
}

View File

@ -0,0 +1,91 @@
package util
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMarkDownHeaderCreation(t *testing.T) {
testCases := []struct {
headers []string
expectedValue string
}{
{nil, ""},
{[]string{}, ""},
{[]string{"one"}, "| one |\n| --- |\n"},
{[]string{"one", "two"}, "| one | two |\n| --- | --- |\n"},
{[]string{"one", "two", "three"}, "| one | two | three |\n| --- | --- | --- |\n"},
}
for _, currentTestCase := range testCases {
t.Run(strings.Join(currentTestCase.headers, ","), func(t1 *testing.T) {
assert.Equal(t1, CreateTableHeader(currentTestCase.headers...), currentTestCase.expectedValue)
})
}
}
func TestCreateTemplateInfoTableTooManyColumns(t *testing.T) {
table, err := CreateTable([]string{"one", "two"}, [][]string{
{"a", "b", "c"},
{"d"},
{"e", "f", "g"},
{"h", "i"},
})
assert.NotNil(t, err)
assert.Empty(t, table)
}
func TestCreateTemplateInfoTable1Column(t *testing.T) {
table, err := CreateTable([]string{"one"}, [][]string{{"a"}, {"b"}, {"c"}})
expected := `| one |
| --- |
| a |
| b |
| c |
`
assert.Nil(t, err)
assert.Equal(t, expected, table)
}
func TestCreateTemplateInfoTable2Columns(t *testing.T) {
table, err := CreateTable([]string{"one", "two"}, [][]string{
{"a", "b"},
{"c"},
{"d", "e"},
})
expected := `| one | two |
| --- | --- |
| a | b |
| c | |
| d | e |
`
assert.Nil(t, err)
assert.Equal(t, expected, table)
}
func TestCreateTemplateInfoTable3Columns(t *testing.T) {
table, err := CreateTable([]string{"one", "two", "three"}, [][]string{
{"a", "b", "c"},
{"d"},
{"e", "f", "g"},
{"h", "i"},
})
expected := `| one | two | three |
| --- | --- | --- |
| a | b | c |
| d | | |
| e | f | g |
| h | i | |
`
assert.Nil(t, err)
assert.Equal(t, expected, table)
}

View File

@ -60,7 +60,7 @@ func (exporter *Exporter) addToolDetails() {
}
exporter.sarif.RegisterTool(driver)
reportloc := sarif.ArtifactLocation{
reportLocation := sarif.ArtifactLocation{
Uri: "file:///" + exporter.options.File,
Description: &sarif.Message{
Text: "Nuclei Sarif Report",
@ -70,7 +70,7 @@ func (exporter *Exporter) addToolDetails() {
invocation := sarif.Invocation{
CommandLine: os.Args[0],
Arguments: os.Args[1:],
ResponseFiles: []sarif.ArtifactLocation{reportloc},
ResponseFiles: []sarif.ArtifactLocation{reportLocation},
}
exporter.sarif.RegisterToolInvocation(invocation)
}
@ -102,10 +102,10 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
resultHeader := fmt.Sprintf("%v (%v) found on %v", event.Info.Name, event.TemplateID, event.Host)
resultLevel, vulnRating := exporter.getSeverity(severity)
// Extra metdata if generated sarif is uploaded to github security page
ghmeta := map[string]interface{}{}
ghmeta["tags"] = []string{"security"}
ghmeta["security-severity"] = vulnRating
// Extra metadata if generated sarif is uploaded to GitHub security page
ghMeta := map[string]interface{}{}
ghMeta["tags"] = []string{"security"}
ghMeta["security-severity"] = vulnRating
// rule contain details of template
rule := sarif.ReportingDescriptor{
@ -115,10 +115,10 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
// Points to template URL
Text: event.Info.Description + "\nMore details at\n" + event.TemplateURL + "\n",
},
Properties: ghmeta,
Properties: ghMeta,
}
// Github Uses ShortDescription as title
// GitHub Uses ShortDescription as title
if event.Info.Description != "" {
rule.ShortDescription = &sarif.MultiformatMessageString{
Text: resultHeader,
@ -141,7 +141,7 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
},
PhysicalLocation: sarif.PhysicalLocation{
ArtifactLocation: sarif.ArtifactLocation{
// github only accepts file:// protocol and local & relative files only
// GitHub only accepts file:// protocol and local & relative files only
// to avoid errors // is used which also translates to file according to specification
Uri: "/" + event.Path,
Description: &sarif.Message{
@ -193,5 +193,4 @@ func (exporter *Exporter) Close() error {
}
return nil
}

View File

@ -1,245 +1,9 @@
package format
import (
"bytes"
"fmt"
"strconv"
"strings"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v2/pkg/utils"
"github.com/projectdiscovery/nuclei/v2/pkg/model"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/types"
)
// Summary returns a formatted built one line summary of the event
func Summary(event *output.ResultEvent) string {
template := GetMatchedTemplate(event)
builder := &strings.Builder{}
builder.WriteString(types.ToString(event.Info.Name))
builder.WriteString(" (")
builder.WriteString(template)
builder.WriteString(") found on ")
builder.WriteString(event.Host)
data := builder.String()
return data
}
// MarkdownDescription formats a short description of the generated
// event by the nuclei scanner in Markdown format.
func MarkdownDescription(event *output.ResultEvent) string { // TODO remove the code duplication: format.go <-> jira.go
template := GetMatchedTemplate(event)
builder := &bytes.Buffer{}
builder.WriteString("**Details**: **")
builder.WriteString(template)
builder.WriteString("** ")
builder.WriteString(" matched at ")
builder.WriteString(event.Host)
builder.WriteString("\n\n**Protocol**: ")
builder.WriteString(strings.ToUpper(event.Type))
builder.WriteString("\n\n**Full URL**: ")
builder.WriteString(event.Matched)
builder.WriteString("\n\n**Timestamp**: ")
builder.WriteString(event.Timestamp.Format("Mon Jan 2 15:04:05 -0700 MST 2006"))
builder.WriteString("\n\n**Template Information**\n\n| Key | Value |\n|---|---|\n")
builder.WriteString(ToMarkdownTableString(&event.Info))
if event.Request != "" {
builder.WriteString(createMarkdownCodeBlock("Request", types.ToHexOrString(event.Request), "http"))
}
if event.Response != "" {
var responseString string
// If the response is larger than 5 kb, truncate it before writing.
if len(event.Response) > 5*1024 {
responseString = (event.Response[:5*1024])
responseString += ".... Truncated ...."
} else {
responseString = event.Response
}
builder.WriteString(createMarkdownCodeBlock("Response", responseString, "http"))
}
if len(event.ExtractedResults) > 0 || len(event.Metadata) > 0 {
builder.WriteString("\n**Extra Information**\n\n")
if len(event.ExtractedResults) > 0 {
builder.WriteString("**Extracted results**:\n\n")
for _, v := range event.ExtractedResults {
builder.WriteString("- ")
builder.WriteString(v)
builder.WriteString("\n")
}
builder.WriteString("\n")
}
if len(event.Metadata) > 0 {
builder.WriteString("**Metadata**:\n\n")
for k, v := range event.Metadata {
builder.WriteString("- ")
builder.WriteString(k)
builder.WriteString(": ")
builder.WriteString(types.ToString(v))
builder.WriteString("\n")
}
builder.WriteString("\n")
}
}
if event.Interaction != nil {
builder.WriteString("**Interaction Data**\n---\n")
builder.WriteString(event.Interaction.Protocol)
if event.Interaction.QType != "" {
builder.WriteString(" (")
builder.WriteString(event.Interaction.QType)
builder.WriteString(")")
}
builder.WriteString(" Interaction from ")
builder.WriteString(event.Interaction.RemoteAddress)
builder.WriteString(" at ")
builder.WriteString(event.Interaction.UniqueID)
if event.Interaction.RawRequest != "" {
builder.WriteString(createMarkdownCodeBlock("Interaction Request", event.Interaction.RawRequest, ""))
}
if event.Interaction.RawResponse != "" {
builder.WriteString(createMarkdownCodeBlock("Interaction Response", event.Interaction.RawResponse, ""))
}
}
reference := event.Info.Reference
if !reference.IsEmpty() {
builder.WriteString("\nReferences: \n")
referenceSlice := reference.ToSlice()
for i, item := range referenceSlice {
builder.WriteString("- ")
builder.WriteString(item)
if len(referenceSlice)-1 != i {
builder.WriteString("\n")
}
}
}
builder.WriteString("\n")
if event.CURLCommand != "" {
builder.WriteString("\n**CURL Command**\n```\n")
builder.WriteString(types.ToHexOrString(event.CURLCommand))
builder.WriteString("\n```")
}
builder.WriteString(fmt.Sprintf("\n---\nGenerated by [Nuclei %s](https://github.com/projectdiscovery/nuclei)", config.Version))
data := builder.String()
return data
}
// GetMatchedTemplate returns the matched template from a result event
func GetMatchedTemplate(event *output.ResultEvent) string {
builder := &strings.Builder{}
builder.WriteString(event.TemplateID)
if event.MatcherName != "" {
builder.WriteString(":")
builder.WriteString(event.MatcherName)
}
if event.ExtractorName != "" {
builder.WriteString(":")
builder.WriteString(event.ExtractorName)
}
template := builder.String()
return template
}
func ToMarkdownTableString(templateInfo *model.Info) string {
fields := utils.NewEmptyInsertionOrderedStringMap(5)
fields.Set("Name", templateInfo.Name)
fields.Set("Authors", templateInfo.Authors.String())
fields.Set("Tags", templateInfo.Tags.String())
fields.Set("Severity", templateInfo.SeverityHolder.Severity.String())
fields.Set("Description", lineBreakToHTML(templateInfo.Description))
fields.Set("Remediation", lineBreakToHTML(templateInfo.Remediation))
classification := templateInfo.Classification
if classification != nil {
if classification.CVSSMetrics != "" {
generateCVSSMetricsFromClassification(classification, fields)
}
generateCVECWEIDLinksFromClassification(classification, fields)
fields.Set("CVSS-Score", strconv.FormatFloat(classification.CVSSScore, 'f', 2, 64))
}
builder := &bytes.Buffer{}
toMarkDownTable := func(insertionOrderedStringMap *utils.InsertionOrderedStringMap) {
insertionOrderedStringMap.ForEach(func(key string, value interface{}) {
switch value := value.(type) {
case string:
if !utils.IsBlank(value) {
builder.WriteString(fmt.Sprintf("| %s | %s |\n", key, value))
}
}
})
}
toMarkDownTable(fields)
toMarkDownTable(utils.NewInsertionOrderedStringMap(templateInfo.Metadata))
return builder.String()
}
func generateCVSSMetricsFromClassification(classification *model.Classification, fields *utils.InsertionOrderedStringMap) {
// Generate cvss link
var cvssLinkPrefix string
if strings.Contains(classification.CVSSMetrics, "CVSS:3.0") {
cvssLinkPrefix = "https://www.first.org/cvss/calculator/3.0#"
} else if strings.Contains(classification.CVSSMetrics, "CVSS:3.1") {
cvssLinkPrefix = "https://www.first.org/cvss/calculator/3.1#"
}
if cvssLinkPrefix != "" {
fields.Set("CVSS-Metrics", fmt.Sprintf("[%s](%s%s)", classification.CVSSMetrics, cvssLinkPrefix, classification.CVSSMetrics))
} else {
fields.Set("CVSS-Metrics", classification.CVSSMetrics)
}
}
func generateCVECWEIDLinksFromClassification(classification *model.Classification, fields *utils.InsertionOrderedStringMap) {
cwes := classification.CWEID.ToSlice()
cweIDs := make([]string, 0, len(cwes))
for _, value := range cwes {
parts := strings.Split(value, "-")
if len(parts) != 2 {
continue
}
cweIDs = append(cweIDs, fmt.Sprintf("[%s](https://cwe.mitre.org/data/definitions/%s.html)", strings.ToUpper(value), parts[1]))
}
if len(cweIDs) > 0 {
fields.Set("CWE-ID", strings.Join(cweIDs, ","))
}
cves := classification.CVEID.ToSlice()
cveIDs := make([]string, 0, len(cves))
for _, value := range cves {
cveIDs = append(cveIDs, fmt.Sprintf("[%s](https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s)", strings.ToUpper(value), value))
}
if len(cveIDs) > 0 {
fields.Set("CVE-ID", strings.Join(cveIDs, ","))
}
}
func createMarkdownCodeBlock(title string, content string, language string) string {
return "\n" + createBoldMarkdown(title) + "\n```" + language + "\n" + content + "\n```\n"
}
func createBoldMarkdown(value string) string {
return "**" + value + "**"
}
func lineBreakToHTML(text string) string {
return strings.ReplaceAll(text, "\n", "<br>")
type ResultFormatter interface {
MakeBold(text string) string
CreateCodeBlock(title string, content string, language string) string
CreateTable(headers []string, rows [][]string) (string, error)
CreateLink(title string, url string) string
CreateHorizontalLine() string
}

View File

@ -0,0 +1,229 @@
package format
import (
"bytes"
"fmt"
"strconv"
"strings"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v2/pkg/model"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
"github.com/projectdiscovery/nuclei/v2/pkg/types"
"github.com/projectdiscovery/nuclei/v2/pkg/utils"
)
// Summary returns a formatted built one line summary of the event
func Summary(event *output.ResultEvent) string {
return fmt.Sprintf("%s (%s) found on %s", types.ToString(event.Info.Name), GetMatchedTemplateName(event), event.Host)
}
// GetMatchedTemplateName returns the matched template name from a result event
// together with the found matcher and extractor name, if present
func GetMatchedTemplateName(event *output.ResultEvent) string {
matchedTemplateName := event.TemplateID
if event.MatcherName != "" {
matchedTemplateName += ":" + event.MatcherName
}
if event.ExtractorName != "" {
matchedTemplateName += ":" + event.ExtractorName
}
return matchedTemplateName
}
func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatter) string {
template := GetMatchedTemplateName(event)
builder := &bytes.Buffer{}
builder.WriteString(fmt.Sprintf("%s: %s matched at %s\n\n", formatter.MakeBold("Details"), formatter.MakeBold(template), event.Host))
attributes := utils.NewEmptyInsertionOrderedStringMap(3)
attributes.Set("Protocol", strings.ToUpper(event.Type))
attributes.Set("Full URL", event.Matched)
attributes.Set("Timestamp", event.Timestamp.Format("Mon Jan 2 15:04:05 -0700 MST 2006"))
attributes.ForEach(func(key string, data interface{}) {
builder.WriteString(fmt.Sprintf("%s: %s\n\n", formatter.MakeBold(key), types.ToString(data)))
})
builder.WriteString(formatter.MakeBold("Template Information"))
builder.WriteString("\n\n")
builder.WriteString(CreateTemplateInfoTable(&event.Info, formatter))
if event.Request != "" {
builder.WriteString(formatter.CreateCodeBlock("Request", types.ToHexOrString(event.Request), "http"))
}
if event.Response != "" {
var responseString string
// If the response is larger than 5 kb, truncate it before writing.
maxKbSize := 5 * 1024
if len(event.Response) > maxKbSize {
responseString = event.Response[:maxKbSize]
responseString += ".... Truncated ...."
} else {
responseString = event.Response
}
builder.WriteString(formatter.CreateCodeBlock("Response", responseString, "http"))
}
if len(event.ExtractedResults) > 0 || len(event.Metadata) > 0 {
builder.WriteString("\n")
builder.WriteString(formatter.MakeBold("Extra Information"))
builder.WriteString("\n\n")
if len(event.ExtractedResults) > 0 {
builder.WriteString(formatter.MakeBold("Extracted results:"))
builder.WriteString("\n\n")
for _, v := range event.ExtractedResults {
builder.WriteString("- ")
builder.WriteString(v)
builder.WriteString("\n")
}
builder.WriteString("\n")
}
if len(event.Metadata) > 0 {
builder.WriteString(formatter.MakeBold("Metadata:"))
builder.WriteString("\n\n")
for k, v := range event.Metadata {
builder.WriteString("- ")
builder.WriteString(k)
builder.WriteString(": ")
builder.WriteString(types.ToString(v))
builder.WriteString("\n")
}
builder.WriteString("\n")
}
}
if event.Interaction != nil {
builder.WriteString(fmt.Sprintf("%s\n%s", formatter.MakeBold("Interaction Data"), formatter.CreateHorizontalLine()))
builder.WriteString(event.Interaction.Protocol)
if event.Interaction.QType != "" {
builder.WriteString(fmt.Sprintf(" (%s)", event.Interaction.QType))
}
builder.WriteString(fmt.Sprintf(" Interaction from %s at %s", event.Interaction.RemoteAddress, event.Interaction.UniqueID))
if event.Interaction.RawRequest != "" {
builder.WriteString(formatter.CreateCodeBlock("Interaction Request", event.Interaction.RawRequest, ""))
}
if event.Interaction.RawResponse != "" {
builder.WriteString(formatter.CreateCodeBlock("Interaction Response", event.Interaction.RawResponse, ""))
}
}
reference := event.Info.Reference
if !reference.IsEmpty() {
builder.WriteString("\nReferences: \n")
referenceSlice := reference.ToSlice()
for i, item := range referenceSlice {
builder.WriteString("- ")
builder.WriteString(item)
if len(referenceSlice)-1 != i {
builder.WriteString("\n")
}
}
}
builder.WriteString("\n")
if event.CURLCommand != "" {
builder.WriteString(
formatter.CreateCodeBlock("CURL command", types.ToHexOrString(event.CURLCommand), "sh"),
)
}
builder.WriteString("\n" + formatter.CreateHorizontalLine() + "\n")
builder.WriteString(fmt.Sprintf("Generated by %s", formatter.CreateLink("Nuclei "+config.Version, "https://github.com/projectdiscovery/nuclei")))
data := builder.String()
return data
}
func CreateTemplateInfoTable(templateInfo *model.Info, formatter ResultFormatter) string {
rows := [][]string{
{"Name", templateInfo.Name},
{"Authors", templateInfo.Authors.String()},
{"Tags", templateInfo.Tags.String()},
{"Severity", templateInfo.SeverityHolder.Severity.String()},
}
if !utils.IsBlank(templateInfo.Description) {
rows = append(rows, []string{"Description", lineBreakToHTML(templateInfo.Description)})
}
if !utils.IsBlank(templateInfo.Remediation) {
rows = append(rows, []string{"Remediation", lineBreakToHTML(templateInfo.Remediation)})
}
classification := templateInfo.Classification
if classification != nil {
if classification.CVSSMetrics != "" {
rows = append(rows, []string{"CVSS-Metrics", generateCVSSMetricsFromClassification(classification)})
}
rows = append(rows, generateCVECWEIDLinksFromClassification(classification)...)
rows = append(rows, []string{"CVSS-Score", strconv.FormatFloat(classification.CVSSScore, 'f', 2, 64)})
}
for key, value := range templateInfo.Metadata {
switch value := value.(type) {
case string:
if !utils.IsBlank(value) {
rows = append(rows, []string{key, value})
}
}
}
table, _ := formatter.CreateTable([]string{"Key", "Value"}, rows)
return table
}
func generateCVSSMetricsFromClassification(classification *model.Classification) string {
var cvssLinkPrefix string
if strings.Contains(classification.CVSSMetrics, "CVSS:3.0") {
cvssLinkPrefix = "https://www.first.org/cvss/calculator/3.0#"
} else if strings.Contains(classification.CVSSMetrics, "CVSS:3.1") {
cvssLinkPrefix = "https://www.first.org/cvss/calculator/3.1#"
}
if cvssLinkPrefix == "" {
return classification.CVSSMetrics
} else {
return util.CreateLink(classification.CVSSMetrics, cvssLinkPrefix+classification.CVSSMetrics)
}
}
func generateCVECWEIDLinksFromClassification(classification *model.Classification) [][]string {
cwes := classification.CWEID.ToSlice()
cweIDs := make([]string, 0, len(cwes))
for _, value := range cwes {
parts := strings.Split(value, "-")
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])))
}
var rows [][]string
if len(cweIDs) > 0 {
rows = append(rows, []string{"CWE-ID", strings.Join(cweIDs, ",")})
}
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)))
}
if len(cveIDs) > 0 {
rows = append(rows, []string{"CVE-ID", strings.Join(cveIDs, ",")})
}
return rows
}
func lineBreakToHTML(text string) string {
return strings.ReplaceAll(text, "\n", "<br>")
}

View File

@ -1,14 +1,14 @@
package format
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/projectdiscovery/nuclei/v2/pkg/model"
"github.com/projectdiscovery/nuclei/v2/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v2/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
)
func TestToMarkdownTableString(t *testing.T) {
@ -25,9 +25,11 @@ func TestToMarkdownTableString(t *testing.T) {
},
}
result := ToMarkdownTableString(&info)
result := CreateTemplateInfoTable(&info, &util.MarkdownFormatter{})
expectedOrderedAttributes := `| Name | Test Template Name |
expectedOrderedAttributes := `| Key | Value |
| --- | --- |
| Name | Test Template Name |
| Authors | forgedhallpass, ice3man |
| Tags | cve, misc |
| Severity | high |

View File

@ -7,12 +7,12 @@ import (
"net/url"
"strings"
"golang.org/x/oauth2"
"github.com/google/go-github/github"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/format"
"github.com/projectdiscovery/nuclei/v2/pkg/types"
"github.com/projectdiscovery/retryablehttp-go"
@ -28,7 +28,7 @@ type Integration struct {
type Options struct {
// BaseURL (optional) is the self-hosted GitHub application url
BaseURL string `yaml:"base-url" validate:"omitempty,url"`
// Username is the username of the github user
// Username is the username of the GitHub user
Username string `yaml:"username" validate:"required"`
// Owner is the owner name of the repository for issues.
Owner string `yaml:"owner" validate:"required"`
@ -78,7 +78,7 @@ func New(options *Options) (*Integration, error) {
// CreateIssue creates an issue in the tracker
func (i *Integration) CreateIssue(event *output.ResultEvent) error {
summary := format.Summary(event)
description := format.MarkdownDescription(event)
description := format.CreateReportDescription(event, util.MarkdownFormatter{})
labels := []string{}
severityLabel := fmt.Sprintf("Severity: %s", event.Info.SeverityHolder.Severity.String())
if i.options.SeverityAsLabel && severityLabel != "" {

View File

@ -6,6 +6,7 @@ import (
"github.com/xanzy/go-gitlab"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/format"
"github.com/projectdiscovery/retryablehttp-go"
)
@ -59,7 +60,7 @@ func New(options *Options) (*Integration, error) {
// CreateIssue creates an issue in the tracker
func (i *Integration) CreateIssue(event *output.ResultEvent) error {
summary := format.Summary(event)
description := format.MarkdownDescription(event)
description := format.CreateReportDescription(event, util.MarkdownFormatter{})
labels := []string{}
severityLabel := fmt.Sprintf("Severity: %s", event.Info.SeverityHolder.Severity.String())
if i.options.SeverityAsLabel && severityLabel != "" {

View File

@ -1,7 +1,6 @@
package jira
import (
"bytes"
"fmt"
"io"
"strings"
@ -10,15 +9,41 @@ import (
"github.com/trivago/tgo/tcontainer"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/exporters/markdown/util"
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/format"
"github.com/projectdiscovery/nuclei/v2/pkg/types"
"github.com/projectdiscovery/retryablehttp-go"
)
type Formatter struct {
util.MarkdownFormatter
}
func (jiraFormatter *Formatter) MakeBold(text string) string {
return "*" + text + "*"
}
func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {
return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), content)
}
func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {
table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)
if err != nil {
return "", err
}
tableRows := strings.Split(table, "\n")
tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)
return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil
}
func (jiraFormatter *Formatter) CreateLink(title string, url string) string {
return fmt.Sprintf("[%s|%s]", title, url)
}
// Integration is a client for an issue tracker integration
type Integration struct {
Formatter
jira *jira.Client
options *Options
}
@ -129,7 +154,7 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error {
}
}
fields := &jira.IssueFields{
Description: jiraFormatDescription(event),
Description: format.CreateReportDescription(event, i),
Unknowns: customFields,
Type: jira.IssueType{Name: i.options.IssueType},
Project: jira.Project{Key: i.options.ProjectName},
@ -139,7 +164,7 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error {
if !i.options.Cloud {
fields = &jira.IssueFields{
Assignee: &jira.User{Name: i.options.AccountID},
Description: jiraFormatDescription(event),
Description: format.CreateReportDescription(event, i),
Type: jira.IssueType{Name: i.options.IssueType},
Project: jira.Project{Key: i.options.ProjectName},
Summary: summary,
@ -171,7 +196,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error {
return err
} else if issueID != "" {
_, _, err = i.jira.Issue.AddComment(issueID, &jira.Comment{
Body: jiraFormatDescription(event),
Body: format.CreateReportDescription(event, i),
})
return err
}
@ -181,7 +206,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error {
// FindExistingIssue checks if the issue already exists and returns its ID
func (i *Integration) FindExistingIssue(event *output.ResultEvent) (string, error) {
template := format.GetMatchedTemplate(event)
template := format.GetMatchedTemplateName(event)
jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND status != \"%s\"", template, event.Host, i.options.StatusNot)
searchOptions := &jira.SearchOptions{
@ -208,117 +233,3 @@ func (i *Integration) FindExistingIssue(event *output.ResultEvent) (string, erro
return chunk[0].ID, nil
}
}
// jiraFormatDescription formats a short description of the generated
// event by the nuclei scanner in Jira format.
func jiraFormatDescription(event *output.ResultEvent) string { // TODO remove the code duplication: format.go <-> jira.go
template := format.GetMatchedTemplate(event)
builder := &bytes.Buffer{}
builder.WriteString("*Details*: *")
builder.WriteString(template)
builder.WriteString("* ")
builder.WriteString(" matched at ")
builder.WriteString(event.Host)
builder.WriteString("\n\n*Protocol*: ")
builder.WriteString(strings.ToUpper(event.Type))
builder.WriteString("\n\n*Full URL*: ")
builder.WriteString(event.Matched)
builder.WriteString("\n\n*Timestamp*: ")
builder.WriteString(event.Timestamp.Format("Mon Jan 2 15:04:05 -0700 MST 2006"))
builder.WriteString("\n\n*Template Information*\n\n| Key | Value |\n")
builder.WriteString(format.ToMarkdownTableString(&event.Info))
builder.WriteString(createMarkdownCodeBlock("Request", event.Request))
builder.WriteString("\n*Response*\n\n{code}\n")
// If the response is larger than 5 kb, truncate it before writing.
if len(event.Response) > 5*1024 {
builder.WriteString(event.Response[:5*1024])
builder.WriteString(".... Truncated ....")
} else {
builder.WriteString(event.Response)
}
builder.WriteString("\n{code}\n\n")
if len(event.ExtractedResults) > 0 || len(event.Metadata) > 0 {
builder.WriteString("\n*Extra Information*\n\n")
if len(event.ExtractedResults) > 0 {
builder.WriteString("*Extracted results*:\n\n")
for _, v := range event.ExtractedResults {
builder.WriteString("- ")
builder.WriteString(v)
builder.WriteString("\n")
}
builder.WriteString("\n")
}
if len(event.Metadata) > 0 {
builder.WriteString("*Metadata*:\n\n")
for k, v := range event.Metadata {
builder.WriteString("- ")
builder.WriteString(k)
builder.WriteString(": ")
builder.WriteString(types.ToString(v))
builder.WriteString("\n")
}
builder.WriteString("\n")
}
}
if event.Interaction != nil {
builder.WriteString("*Interaction Data*\n---\n")
builder.WriteString(event.Interaction.Protocol)
if event.Interaction.QType != "" {
builder.WriteString(" (")
builder.WriteString(event.Interaction.QType)
builder.WriteString(")")
}
builder.WriteString(" Interaction from ")
builder.WriteString(event.Interaction.RemoteAddress)
builder.WriteString(" at ")
builder.WriteString(event.Interaction.UniqueID)
if event.Interaction.RawRequest != "" {
builder.WriteString(createMarkdownCodeBlock("Interaction Request", event.Interaction.RawRequest))
}
if event.Interaction.RawResponse != "" {
builder.WriteString(createMarkdownCodeBlock("Interaction Response", event.Interaction.RawResponse))
}
}
reference := event.Info.Reference
if !reference.IsEmpty() {
builder.WriteString("\nReferences: \n")
referenceSlice := reference.ToSlice()
for i, item := range referenceSlice {
builder.WriteString("- ")
builder.WriteString(item)
if len(referenceSlice)-1 != i {
builder.WriteString("\n")
}
}
}
builder.WriteString("\n")
if event.CURLCommand != "" {
builder.WriteString("\n*CURL Command*\n{code}\n")
builder.WriteString(event.CURLCommand)
builder.WriteString("\n{code}")
}
builder.WriteString(fmt.Sprintf("\n---\nGenerated by [Nuclei %s](https://github.com/projectdiscovery/nuclei)", config.Version))
data := builder.String()
return data
}
func createMarkdownCodeBlock(title string, content string) string {
return "\n" + createBoldMarkdown(title) + "\n" + content + "*\n\n{code}"
}
func createBoldMarkdown(value string) string {
return "*" + value + "*\n\n{code}"
}

View File

@ -0,0 +1,37 @@
package jira
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
)
func TestLinkCreation(t *testing.T) {
jiraIntegration := &Integration{}
link := jiraIntegration.CreateLink("ProjectDiscovery", "https://projectdiscovery.io")
assert.Equal(t, "[ProjectDiscovery|https://projectdiscovery.io]", link)
}
func TestHorizontalLineCreation(t *testing.T) {
jiraIntegration := &Integration{}
horizontalLine := jiraIntegration.CreateHorizontalLine()
assert.True(t, strings.Contains(horizontalLine, "----"))
}
func TestTableCreation(t *testing.T) {
jiraIntegration := &Integration{}
table, err := jiraIntegration.CreateTable([]string{"key", "value"}, [][]string{
{"a", "b"},
{"c"},
{"d", "e"},
})
assert.Nil(t, err)
expected := `| key | value |
| a | b |
| c | |
| d | e |
`
assert.Equal(t, expected, table)
}