Path: blob/dev/pkg/reporting/trackers/jira/jira.go
2070 views
package jira12import (3"fmt"4"io"5"net/http"6"net/url"7"strings"8"sync"910"github.com/andygrunwald/go-jira"11"github.com/pkg/errors"12"github.com/trivago/tgo/tcontainer"1314"github.com/projectdiscovery/gologger"15"github.com/projectdiscovery/nuclei/v3/pkg/output"16"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"17"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"18"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"19"github.com/projectdiscovery/retryablehttp-go"20"github.com/projectdiscovery/utils/ptr"21)2223type Formatter struct {24util.MarkdownFormatter25}2627func (jiraFormatter *Formatter) MakeBold(text string) string {28return "*" + text + "*"29}3031func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {32escapedContent := strings.ReplaceAll(content, "{code}", "")33return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)34}3536func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {37table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)38if err != nil {39return "", err40}41tableRows := strings.Split(table, "\n")42tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)43return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil44}4546func (jiraFormatter *Formatter) CreateLink(title string, url string) string {47return fmt.Sprintf("[%s|%s]", title, url)48}4950// Integration is a client for an issue tracker integration51type Integration struct {52Formatter53jira *jira.Client54options *Options5556once *sync.Once57transitionID string58}5960// Options contains the configuration options for jira client61type Options struct {62// Cloud value (optional) is set to true when Jira cloud is used63Cloud bool `yaml:"cloud" json:"cloud"`64// UpdateExisting value (optional) if true, the existing opened issue is updated65UpdateExisting bool `yaml:"update-existing" json:"update_existing"`66// URL is the URL of the jira server67URL string `yaml:"url" json:"url" validate:"required"`68// AccountID is the accountID of the jira user.69AccountID string `yaml:"account-id" json:"account_id" validate:"required"`70// Email is the email of the user for jira instance71Email string `yaml:"email" json:"email"`72// PersonalAccessToken is the personal access token for jira instance.73// If this is set, Bearer Auth is used instead of Basic Auth.74PersonalAccessToken string `yaml:"personal-access-token" json:"personal_access_token"`75// Token is the token for jira instance.76Token string `yaml:"token" json:"token"`77// ProjectName is the name of the project.78ProjectName string `yaml:"project-name" json:"project_name"`79// ProjectID is the ID of the project (optional)80ProjectID string `yaml:"project-id" json:"project_id"`81// IssueType (optional) is the name of the created issue type82IssueType string `yaml:"issue-type" json:"issue_type"`83// IssueTypeID (optional) is the ID of the created issue type84IssueTypeID string `yaml:"issue-type-id" json:"issue_type_id"`85// SeverityAsLabel (optional) sends the severity as the label of the created86// issue.87SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`88// AllowList contains a list of allowed events for this tracker89AllowList *filters.Filter `yaml:"allow-list"`90// DenyList contains a list of denied events for this tracker91DenyList *filters.Filter `yaml:"deny-list"`92// Severity (optional) is the severity of the issue.93Severity []string `yaml:"severity" json:"severity"`94HttpClient *retryablehttp.Client `yaml:"-" json:"-"`95// for each customfield specified in the configuration options96// we will create a map of customfield name to the value97// that will be used to create the issue98CustomFields map[string]interface{} `yaml:"custom-fields" json:"custom_fields"`99StatusNot string `yaml:"status-not" json:"status_not"`100OmitRaw bool `yaml:"-"`101}102103// New creates a new issue tracker integration client based on options.104func New(options *Options) (*Integration, error) {105username := options.Email106if !options.Cloud {107username = options.AccountID108}109110var httpclient *http.Client111if options.PersonalAccessToken != "" {112bearerTp := jira.BearerAuthTransport{113Token: options.PersonalAccessToken,114}115if options.HttpClient != nil {116bearerTp.Transport = options.HttpClient.HTTPClient.Transport117}118httpclient = bearerTp.Client()119} else {120basicTp := jira.BasicAuthTransport{121Username: username,122Password: options.Token,123}124if options.HttpClient != nil {125basicTp.Transport = options.HttpClient.HTTPClient.Transport126}127httpclient = basicTp.Client()128}129130jiraClient, err := jira.NewClient(httpclient, options.URL)131if err != nil {132return nil, err133}134integration := &Integration{135jira: jiraClient,136options: options,137once: &sync.Once{},138}139return integration, nil140}141142func (i *Integration) Name() string {143return "jira"144}145146// CreateNewIssue creates a new issue in the tracker147func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {148summary := format.Summary(event)149labels := []string{}150severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())151if i.options.SeverityAsLabel && severityLabel != "" {152labels = append(labels, severityLabel)153}154if label := i.options.IssueType; label != "" {155labels = append(labels, label)156}157// for each custom value, take the name of the custom field and158// set the value of the custom field to the value specified in the159// configuration options160customFields := tcontainer.NewMarshalMap()161for name, value := range i.options.CustomFields {162//customFields[name] = map[string]interface{}{"value": value}163if valueMap, ok := value.(map[interface{}]interface{}); ok {164// Iterate over nested map165for nestedName, nestedValue := range valueMap {166fmtNestedValue, ok := nestedValue.(string)167if !ok {168return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue)169}170if strings.HasPrefix(fmtNestedValue, "$") {171nestedValue = strings.TrimPrefix(fmtNestedValue, "$")172switch nestedValue {173case "CVSSMetrics":174nestedValue = ptr.Safe(event.Info.Classification).CVSSMetrics175case "CVEID":176nestedValue = ptr.Safe(event.Info.Classification).CVEID177case "CWEID":178nestedValue = ptr.Safe(event.Info.Classification).CWEID179case "CVSSScore":180nestedValue = ptr.Safe(event.Info.Classification).CVSSScore181case "Host":182nestedValue = event.Host183case "Severity":184nestedValue = event.Info.SeverityHolder185case "Name":186nestedValue = event.Info.Name187}188}189switch nestedName {190case "id":191customFields[name] = map[string]interface{}{"id": nestedValue}192case "name":193customFields[name] = map[string]interface{}{"value": nestedValue}194case "freeform":195customFields[name] = nestedValue196}197}198}199}200fields := &jira.IssueFields{201Assignee: &jira.User{Name: i.options.AccountID},202Description: format.CreateReportDescription(event, i, i.options.OmitRaw),203Unknowns: customFields,204Labels: labels,205Type: jira.IssueType{Name: i.options.IssueType},206Project: jira.Project{Key: i.options.ProjectName},207Summary: summary,208}209210// On-prem version of Jira server does not use AccountID211if !i.options.Cloud {212fields = &jira.IssueFields{213Assignee: &jira.User{Name: i.options.AccountID},214Description: format.CreateReportDescription(event, i, i.options.OmitRaw),215Type: jira.IssueType{Name: i.options.IssueType},216Project: jira.Project{Key: i.options.ProjectName},217Summary: summary,218Labels: labels,219Unknowns: customFields,220}221}222if i.options.IssueTypeID != "" {223fields.Type = jira.IssueType{ID: i.options.IssueTypeID}224}225if i.options.ProjectID != "" {226fields.Project = jira.Project{ID: i.options.ProjectID}227}228229issueData := &jira.Issue{230Fields: fields,231}232createdIssue, resp, err := i.jira.Issue.Create(issueData)233if err != nil {234var data string235if resp != nil && resp.Body != nil {236d, _ := io.ReadAll(resp.Body)237data = string(d)238}239return nil, fmt.Errorf("%w => %s", err, data)240}241return getIssueResponseFromJira(createdIssue)242}243244func getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) {245parsed, err := url.Parse(issue.Self)246if err != nil {247return nil, err248}249parsed.Path = fmt.Sprintf("/browse/%s", issue.Key)250issueURL := parsed.String()251252return &filters.CreateIssueResponse{253IssueID: issue.ID,254IssueURL: issueURL,255}, nil256}257258// CreateIssue creates an issue in the tracker or updates the existing one259func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {260if i.options.UpdateExisting {261issue, err := i.FindExistingIssue(event, true)262if err != nil {263return nil, errors.Wrap(err, "could not find existing issue")264} else if issue.ID != "" {265_, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{266Body: format.CreateReportDescription(event, i, i.options.OmitRaw),267})268if err != nil {269return nil, errors.Wrap(err, "could not add comment to existing issue")270}271return getIssueResponseFromJira(&issue)272}273}274resp, err := i.CreateNewIssue(event)275if err != nil {276return nil, errors.Wrap(err, "could not create new issue")277}278return resp, nil279}280281func (i *Integration) CloseIssue(event *output.ResultEvent) error {282if i.options.StatusNot == "" {283return nil284}285286issue, err := i.FindExistingIssue(event, false)287if err != nil {288return err289} else if issue.ID != "" {290// Lazy load the transitions ID in case it's not set291i.once.Do(func() {292transitions, _, err := i.jira.Issue.GetTransitions(issue.ID)293if err != nil {294return295}296for _, transition := range transitions {297if transition.Name == i.options.StatusNot {298i.transitionID = transition.ID299break300}301}302})303if i.transitionID == "" {304return nil305}306transition := jira.CreateTransitionPayload{307Transition: jira.TransitionPayload{308ID: i.transitionID,309},310}311312_, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition)313if err != nil {314return err315}316}317return nil318}319320// FindExistingIssue checks if the issue already exists and returns its ID321func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {322template := format.GetMatchedTemplateName(event)323project := i.options.ProjectName324if i.options.ProjectID != "" {325project = i.options.ProjectID326}327jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)328if useStatus {329jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)330}331332searchOptions := &jira.SearchOptions{333MaxResults: 1, // if any issue exists, then we won't create a new one334}335336chunk, resp, err := i.jira.Issue.Search(jql, searchOptions)337if err != nil {338var data string339if resp != nil && resp.Body != nil {340d, _ := io.ReadAll(resp.Body)341data = string(d)342}343return jira.Issue{}, fmt.Errorf("%w => %s", err, data)344}345346switch resp.Total {347case 0:348return jira.Issue{}, nil349case 1:350return chunk[0], nil351default:352gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, chunk[0].ID)353return chunk[0], nil354}355}356357// ShouldFilter determines if an issue should be logged to this tracker358func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {359if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {360return false361}362363if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {364return false365}366367return true368}369370371