2021-02-02 12:10:47 +05:30
|
|
|
package jira
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"fmt"
|
2022-02-23 13:54:46 +01:00
|
|
|
"io"
|
2021-02-02 12:10:47 +05:30
|
|
|
"strings"
|
|
|
|
|
|
2021-07-19 21:04:08 +03:00
|
|
|
"github.com/andygrunwald/go-jira"
|
2023-03-10 15:55:54 -07:00
|
|
|
"github.com/trivago/tgo/tcontainer"
|
2021-11-25 15:18:46 +02:00
|
|
|
|
2021-09-01 11:43:02 +02:00
|
|
|
"github.com/projectdiscovery/gologger"
|
2021-09-09 19:56:39 +05:30
|
|
|
"github.com/projectdiscovery/nuclei/v2/pkg/catalog/config"
|
2021-02-02 12:10:47 +05:30
|
|
|
"github.com/projectdiscovery/nuclei/v2/pkg/output"
|
2021-03-22 14:03:05 +05:30
|
|
|
"github.com/projectdiscovery/nuclei/v2/pkg/reporting/format"
|
2021-02-02 12:10:47 +05:30
|
|
|
"github.com/projectdiscovery/nuclei/v2/pkg/types"
|
2022-03-09 12:31:12 +01:00
|
|
|
"github.com/projectdiscovery/retryablehttp-go"
|
2021-02-02 12:10:47 +05:30
|
|
|
)
|
|
|
|
|
|
2021-08-05 18:19:59 +03:00
|
|
|
// Integration is a client for an issue tracker integration
|
2021-02-02 12:10:47 +05:30
|
|
|
type Integration struct {
|
|
|
|
|
jira *jira.Client
|
|
|
|
|
options *Options
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Options contains the configuration options for jira client
|
|
|
|
|
type Options struct {
|
2021-10-18 20:52:35 +02:00
|
|
|
// Cloud value (optional) is set to true when Jira cloud is used
|
2023-01-10 22:49:01 +05:30
|
|
|
Cloud bool `yaml:"cloud" json:"cloud"`
|
2021-10-18 20:52:35 +02:00
|
|
|
// UpdateExisting value (optional) if true, the existing opened issue is updated
|
2023-01-10 22:49:01 +05:30
|
|
|
UpdateExisting bool `yaml:"update-existing" json:"update_existing"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// URL is the URL of the jira server
|
2023-01-10 22:49:01 +05:30
|
|
|
URL string `yaml:"url" json:"url" validate:"required"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// AccountID is the accountID of the jira user.
|
2023-01-10 22:49:01 +05:30
|
|
|
AccountID string `yaml:"account-id" json:"account_id" validate:"required"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// Email is the email of the user for jira instance
|
2023-01-10 22:49:01 +05:30
|
|
|
Email string `yaml:"email" json:"email" validate:"required,email"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// Token is the token for jira instance.
|
2023-01-10 22:49:01 +05:30
|
|
|
Token string `yaml:"token" json:"token" validate:"required"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// ProjectName is the name of the project.
|
2023-01-10 22:49:01 +05:30
|
|
|
ProjectName string `yaml:"project-name" json:"project_name" validate:"required"`
|
2021-10-18 20:52:35 +02:00
|
|
|
// IssueType (optional) is the name of the created issue type
|
2023-01-10 22:49:01 +05:30
|
|
|
IssueType string `yaml:"issue-type" json:"issue_type"`
|
2021-10-19 17:15:58 +02:00
|
|
|
// SeverityAsLabel (optional) sends the severity as the label of the created
|
|
|
|
|
// issue.
|
2023-01-10 22:49:01 +05:30
|
|
|
SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`
|
|
|
|
|
// Severity (optional) is the severity of the issue.
|
2023-03-10 15:55:54 -07:00
|
|
|
Severity []string `yaml:"severity" json:"severity"`
|
2023-01-10 22:49:01 +05:30
|
|
|
HttpClient *retryablehttp.Client `yaml:"-" json:"-"`
|
2023-03-10 15:55:54 -07:00
|
|
|
// for each customfield specified in the configuration options
|
|
|
|
|
// we will create a map of customfield name to the value
|
|
|
|
|
// that will be used to create the issue
|
|
|
|
|
CustomFields map[string]interface{} `yaml:"custom_fields"`
|
|
|
|
|
StatusNot string `yaml:"status-not" json:"status_not"`
|
2022-09-27 02:40:34 +05:30
|
|
|
}
|
2022-05-12 05:10:14 -05:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
// New creates a new issue tracker integration client based on options.
|
|
|
|
|
func New(options *Options) (*Integration, error) {
|
2021-06-04 13:11:09 +02:00
|
|
|
username := options.Email
|
|
|
|
|
if !options.Cloud {
|
|
|
|
|
username = options.AccountID
|
|
|
|
|
}
|
2021-02-02 12:10:47 +05:30
|
|
|
tp := jira.BasicAuthTransport{
|
2021-06-04 13:11:09 +02:00
|
|
|
Username: username,
|
2021-02-02 12:10:47 +05:30
|
|
|
Password: options.Token,
|
|
|
|
|
}
|
2022-03-09 12:31:12 +01:00
|
|
|
if options.HttpClient != nil {
|
|
|
|
|
tp.Transport = options.HttpClient.HTTPClient.Transport
|
|
|
|
|
}
|
2021-02-02 12:10:47 +05:30
|
|
|
jiraClient, err := jira.NewClient(tp.Client(), options.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &Integration{jira: jiraClient, options: options}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2021-06-04 18:19:23 +02:00
|
|
|
// CreateNewIssue creates a new issue in the tracker
|
|
|
|
|
func (i *Integration) CreateNewIssue(event *output.ResultEvent) error {
|
2021-02-02 12:10:47 +05:30
|
|
|
summary := format.Summary(event)
|
2021-10-18 20:45:46 +02:00
|
|
|
labels := []string{}
|
2022-03-07 17:18:27 +01:00
|
|
|
severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())
|
2021-10-19 17:15:58 +02:00
|
|
|
if i.options.SeverityAsLabel && severityLabel != "" {
|
2021-10-18 20:45:46 +02:00
|
|
|
labels = append(labels, severityLabel)
|
|
|
|
|
}
|
|
|
|
|
if label := i.options.IssueType; label != "" {
|
|
|
|
|
labels = append(labels, label)
|
|
|
|
|
}
|
2023-03-10 15:55:54 -07:00
|
|
|
// for each custom value, take the name of the custom field and
|
|
|
|
|
// set the value of the custom field to the value specified in the
|
|
|
|
|
// configuration options
|
|
|
|
|
customFields := tcontainer.NewMarshalMap()
|
|
|
|
|
for name, value := range i.options.CustomFields {
|
|
|
|
|
//customFields[name] = map[string]interface{}{"value": value}
|
|
|
|
|
if valueMap, ok := value.(map[interface{}]interface{}); ok {
|
|
|
|
|
// Iterate over nested map
|
|
|
|
|
for nestedName, nestedValue := range valueMap {
|
|
|
|
|
if strings.HasPrefix(nestedValue.(string), "$") {
|
|
|
|
|
nestedValue = strings.TrimPrefix(nestedValue.(string), "$")
|
|
|
|
|
switch nestedValue {
|
|
|
|
|
case "CVSSMetrics":
|
|
|
|
|
nestedValue = event.Info.Classification.CVSSMetrics
|
|
|
|
|
case "CVEID":
|
|
|
|
|
nestedValue = event.Info.Classification.CVEID
|
|
|
|
|
case "CWEID":
|
|
|
|
|
nestedValue = event.Info.Classification.CWEID
|
|
|
|
|
case "CVSSScore":
|
|
|
|
|
nestedValue = event.Info.Classification.CVSSScore
|
|
|
|
|
case "Host":
|
|
|
|
|
nestedValue = event.Host
|
|
|
|
|
case "Severity":
|
|
|
|
|
nestedValue = event.Info.SeverityHolder
|
|
|
|
|
case "Name":
|
|
|
|
|
nestedValue = event.Info.Name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
switch nestedName {
|
|
|
|
|
case "id":
|
|
|
|
|
customFields[name] = map[string]interface{}{"id": nestedValue}
|
|
|
|
|
case "name":
|
|
|
|
|
customFields[name] = map[string]interface{}{"value": nestedValue}
|
|
|
|
|
case "freeform":
|
|
|
|
|
customFields[name] = nestedValue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-06-04 18:19:23 +02:00
|
|
|
fields := &jira.IssueFields{
|
|
|
|
|
Description: jiraFormatDescription(event),
|
2023-03-10 15:55:54 -07:00
|
|
|
Unknowns: customFields,
|
2021-06-04 18:19:23 +02:00
|
|
|
Type: jira.IssueType{Name: i.options.IssueType},
|
|
|
|
|
Project: jira.Project{Key: i.options.ProjectName},
|
|
|
|
|
Summary: summary,
|
|
|
|
|
}
|
|
|
|
|
// On-prem version of Jira server does not use AccountID
|
|
|
|
|
if !i.options.Cloud {
|
|
|
|
|
fields = &jira.IssueFields{
|
|
|
|
|
Assignee: &jira.User{Name: i.options.AccountID},
|
2021-02-02 12:10:47 +05:30
|
|
|
Description: jiraFormatDescription(event),
|
|
|
|
|
Type: jira.IssueType{Name: i.options.IssueType},
|
|
|
|
|
Project: jira.Project{Key: i.options.ProjectName},
|
|
|
|
|
Summary: summary,
|
2022-03-07 17:11:20 +01:00
|
|
|
Labels: labels,
|
2023-03-10 15:55:54 -07:00
|
|
|
Unknowns: customFields,
|
2021-06-04 13:11:09 +02:00
|
|
|
}
|
2021-06-04 18:19:23 +02:00
|
|
|
}
|
2021-06-04 17:14:26 +02:00
|
|
|
|
2021-06-04 18:19:23 +02:00
|
|
|
issueData := &jira.Issue{
|
|
|
|
|
Fields: fields,
|
|
|
|
|
}
|
|
|
|
|
_, resp, err := i.jira.Issue.Create(issueData)
|
|
|
|
|
if err != nil {
|
|
|
|
|
var data string
|
|
|
|
|
if resp != nil && resp.Body != nil {
|
2022-02-23 13:54:46 +01:00
|
|
|
d, _ := io.ReadAll(resp.Body)
|
2021-06-04 18:19:23 +02:00
|
|
|
data = string(d)
|
2021-06-04 17:14:26 +02:00
|
|
|
}
|
2021-11-25 15:18:46 +02:00
|
|
|
return fmt.Errorf("%w => %s", err, data)
|
2021-06-04 18:19:23 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateIssue creates an issue in the tracker or updates the existing one
|
|
|
|
|
func (i *Integration) CreateIssue(event *output.ResultEvent) error {
|
|
|
|
|
if i.options.UpdateExisting {
|
2021-09-01 11:30:22 +02:00
|
|
|
issueID, err := i.FindExistingIssue(event)
|
2021-06-04 17:14:26 +02:00
|
|
|
if err != nil {
|
2021-06-04 18:19:23 +02:00
|
|
|
return err
|
2021-09-01 11:30:22 +02:00
|
|
|
} else if issueID != "" {
|
|
|
|
|
_, _, err = i.jira.Issue.AddComment(issueID, &jira.Comment{
|
2021-06-04 18:19:23 +02:00
|
|
|
Body: jiraFormatDescription(event),
|
|
|
|
|
})
|
|
|
|
|
return err
|
2021-06-04 17:14:26 +02:00
|
|
|
}
|
2021-06-04 13:11:09 +02:00
|
|
|
}
|
2021-06-04 18:19:23 +02:00
|
|
|
return i.CreateNewIssue(event)
|
2021-06-04 17:14:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FindExistingIssue checks if the issue already exists and returns its ID
|
|
|
|
|
func (i *Integration) FindExistingIssue(event *output.ResultEvent) (string, error) {
|
|
|
|
|
template := format.GetMatchedTemplate(event)
|
2023-03-10 15:55:54 -07:00
|
|
|
jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND status != \"%s\"", template, event.Host, i.options.StatusNot)
|
2021-06-04 13:11:09 +02:00
|
|
|
|
2021-09-01 11:30:22 +02:00
|
|
|
searchOptions := &jira.SearchOptions{
|
2021-06-04 17:14:26 +02:00
|
|
|
MaxResults: 1, // if any issue exists, then we won't create a new one
|
2021-02-02 12:10:47 +05:30
|
|
|
}
|
2021-06-04 17:14:26 +02:00
|
|
|
|
2021-09-01 11:30:22 +02:00
|
|
|
chunk, resp, err := i.jira.Issue.Search(jql, searchOptions)
|
2021-02-02 12:10:47 +05:30
|
|
|
if err != nil {
|
|
|
|
|
var data string
|
|
|
|
|
if resp != nil && resp.Body != nil {
|
2022-02-23 13:54:46 +01:00
|
|
|
d, _ := io.ReadAll(resp.Body)
|
2021-02-02 12:10:47 +05:30
|
|
|
data = string(d)
|
|
|
|
|
}
|
2021-11-25 15:18:46 +02:00
|
|
|
return "", fmt.Errorf("%w => %s", err, data)
|
2021-06-04 17:14:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch resp.Total {
|
|
|
|
|
case 0:
|
|
|
|
|
return "", nil
|
|
|
|
|
case 1:
|
|
|
|
|
return chunk[0].ID, nil
|
|
|
|
|
default:
|
2021-06-04 18:19:23 +02:00
|
|
|
gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, chunk[0].ID)
|
|
|
|
|
return chunk[0].ID, nil
|
2021-02-02 12:10:47 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// jiraFormatDescription formats a short description of the generated
|
|
|
|
|
// event by the nuclei scanner in Jira format.
|
2021-08-03 15:05:13 +03:00
|
|
|
func jiraFormatDescription(event *output.ResultEvent) string { // TODO remove the code duplication: format.go <-> jira.go
|
2021-02-26 13:13:11 +05:30
|
|
|
template := format.GetMatchedTemplate(event)
|
2021-02-02 12:10:47 +05:30
|
|
|
|
|
|
|
|
builder := &bytes.Buffer{}
|
|
|
|
|
builder.WriteString("*Details*: *")
|
|
|
|
|
builder.WriteString(template)
|
|
|
|
|
builder.WriteString("* ")
|
2021-08-05 16:22:28 +03:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString(" matched at ")
|
2021-02-26 13:13:11 +05:30
|
|
|
builder.WriteString(event.Host)
|
2021-08-05 16:22:28 +03:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("\n\n*Protocol*: ")
|
2021-02-26 13:13:11 +05:30
|
|
|
builder.WriteString(strings.ToUpper(event.Type))
|
2021-08-05 16:22:28 +03:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("\n\n*Full URL*: ")
|
2021-02-26 13:13:11 +05:30
|
|
|
builder.WriteString(event.Matched)
|
2021-08-05 16:22:28 +03:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("\n\n*Timestamp*: ")
|
2021-02-26 13:13:11 +05:30
|
|
|
builder.WriteString(event.Timestamp.Format("Mon Jan 2 15:04:05 -0700 MST 2006"))
|
2021-07-12 17:20:01 +03:00
|
|
|
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("\n\n*Template Information*\n\n| Key | Value |\n")
|
2021-08-05 18:19:59 +03:00
|
|
|
builder.WriteString(format.ToMarkdownTableString(&event.Info))
|
2021-07-12 17:20:01 +03:00
|
|
|
|
2022-05-12 05:10:14 -05:00
|
|
|
builder.WriteString(createMarkdownCodeBlock("Request", event.Request))
|
2021-08-05 16:22:28 +03:00
|
|
|
|
|
|
|
|
builder.WriteString("\n*Response*\n\n{code}\n")
|
2021-03-22 14:05:49 +05:30
|
|
|
// 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)
|
|
|
|
|
}
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("\n{code}\n\n")
|
|
|
|
|
|
2021-02-26 13:13:11 +05:30
|
|
|
if len(event.ExtractedResults) > 0 || len(event.Metadata) > 0 {
|
2021-06-06 17:38:39 +05:30
|
|
|
builder.WriteString("\n*Extra Information*\n\n")
|
2021-02-26 13:13:11 +05:30
|
|
|
if len(event.ExtractedResults) > 0 {
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("*Extracted results*:\n\n")
|
2021-02-26 13:13:11 +05:30
|
|
|
for _, v := range event.ExtractedResults {
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("- ")
|
|
|
|
|
builder.WriteString(v)
|
|
|
|
|
builder.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("\n")
|
|
|
|
|
}
|
2021-02-26 13:13:11 +05:30
|
|
|
if len(event.Metadata) > 0 {
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("*Metadata*:\n\n")
|
2021-02-26 13:13:11 +05:30
|
|
|
for k, v := range event.Metadata {
|
2021-02-02 12:10:47 +05:30
|
|
|
builder.WriteString("- ")
|
|
|
|
|
builder.WriteString(k)
|
|
|
|
|
builder.WriteString(": ")
|
|
|
|
|
builder.WriteString(types.ToString(v))
|
|
|
|
|
builder.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
builder.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-05-03 14:08:09 +05:30
|
|
|
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 != "" {
|
2022-05-12 05:10:14 -05:00
|
|
|
builder.WriteString(createMarkdownCodeBlock("Interaction Request", event.Interaction.RawRequest))
|
2021-05-03 14:08:09 +05:30
|
|
|
}
|
|
|
|
|
if event.Interaction.RawResponse != "" {
|
2022-05-12 05:10:14 -05:00
|
|
|
builder.WriteString(createMarkdownCodeBlock("Interaction Response", event.Interaction.RawResponse))
|
2021-05-03 14:08:09 +05:30
|
|
|
}
|
|
|
|
|
}
|
2021-06-05 23:00:59 +05:30
|
|
|
|
2021-08-03 14:51:34 +03:00
|
|
|
reference := event.Info.Reference
|
|
|
|
|
if !reference.IsEmpty() {
|
2021-08-18 18:37:43 +03:00
|
|
|
builder.WriteString("\nReferences: \n")
|
2021-06-05 23:00:59 +05:30
|
|
|
|
2021-08-03 15:05:13 +03:00
|
|
|
referenceSlice := reference.ToSlice()
|
|
|
|
|
for i, item := range referenceSlice {
|
|
|
|
|
builder.WriteString("- ")
|
|
|
|
|
builder.WriteString(item)
|
|
|
|
|
if len(referenceSlice)-1 != i {
|
|
|
|
|
builder.WriteString("\n")
|
2021-06-05 23:00:59 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-10-15 13:55:50 +05:30
|
|
|
builder.WriteString("\n")
|
|
|
|
|
|
|
|
|
|
if event.CURLCommand != "" {
|
|
|
|
|
builder.WriteString("\n*CURL Command*\n{code}\n")
|
|
|
|
|
builder.WriteString(event.CURLCommand)
|
|
|
|
|
builder.WriteString("\n{code}")
|
|
|
|
|
}
|
2021-09-09 19:59:42 +05:30
|
|
|
builder.WriteString(fmt.Sprintf("\n---\nGenerated by [Nuclei v%s](https://github.com/projectdiscovery/nuclei)", config.Version))
|
2021-02-02 12:10:47 +05:30
|
|
|
data := builder.String()
|
|
|
|
|
return data
|
|
|
|
|
}
|
2022-05-12 05:10:14 -05:00
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
}
|