Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
projectdiscovery
GitHub Repository: projectdiscovery/nuclei
Path: blob/dev/pkg/reporting/trackers/jira/jira.go
2070 views
1
package jira
2
3
import (
4
"fmt"
5
"io"
6
"net/http"
7
"net/url"
8
"strings"
9
"sync"
10
11
"github.com/andygrunwald/go-jira"
12
"github.com/pkg/errors"
13
"github.com/trivago/tgo/tcontainer"
14
15
"github.com/projectdiscovery/gologger"
16
"github.com/projectdiscovery/nuclei/v3/pkg/output"
17
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
18
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"
19
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
20
"github.com/projectdiscovery/retryablehttp-go"
21
"github.com/projectdiscovery/utils/ptr"
22
)
23
24
type Formatter struct {
25
util.MarkdownFormatter
26
}
27
28
func (jiraFormatter *Formatter) MakeBold(text string) string {
29
return "*" + text + "*"
30
}
31
32
func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {
33
escapedContent := strings.ReplaceAll(content, "{code}", "")
34
return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)
35
}
36
37
func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {
38
table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)
39
if err != nil {
40
return "", err
41
}
42
tableRows := strings.Split(table, "\n")
43
tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)
44
return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil
45
}
46
47
func (jiraFormatter *Formatter) CreateLink(title string, url string) string {
48
return fmt.Sprintf("[%s|%s]", title, url)
49
}
50
51
// Integration is a client for an issue tracker integration
52
type Integration struct {
53
Formatter
54
jira *jira.Client
55
options *Options
56
57
once *sync.Once
58
transitionID string
59
}
60
61
// Options contains the configuration options for jira client
62
type Options struct {
63
// Cloud value (optional) is set to true when Jira cloud is used
64
Cloud bool `yaml:"cloud" json:"cloud"`
65
// UpdateExisting value (optional) if true, the existing opened issue is updated
66
UpdateExisting bool `yaml:"update-existing" json:"update_existing"`
67
// URL is the URL of the jira server
68
URL string `yaml:"url" json:"url" validate:"required"`
69
// AccountID is the accountID of the jira user.
70
AccountID string `yaml:"account-id" json:"account_id" validate:"required"`
71
// Email is the email of the user for jira instance
72
Email string `yaml:"email" json:"email"`
73
// PersonalAccessToken is the personal access token for jira instance.
74
// If this is set, Bearer Auth is used instead of Basic Auth.
75
PersonalAccessToken string `yaml:"personal-access-token" json:"personal_access_token"`
76
// Token is the token for jira instance.
77
Token string `yaml:"token" json:"token"`
78
// ProjectName is the name of the project.
79
ProjectName string `yaml:"project-name" json:"project_name"`
80
// ProjectID is the ID of the project (optional)
81
ProjectID string `yaml:"project-id" json:"project_id"`
82
// IssueType (optional) is the name of the created issue type
83
IssueType string `yaml:"issue-type" json:"issue_type"`
84
// IssueTypeID (optional) is the ID of the created issue type
85
IssueTypeID string `yaml:"issue-type-id" json:"issue_type_id"`
86
// SeverityAsLabel (optional) sends the severity as the label of the created
87
// issue.
88
SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`
89
// AllowList contains a list of allowed events for this tracker
90
AllowList *filters.Filter `yaml:"allow-list"`
91
// DenyList contains a list of denied events for this tracker
92
DenyList *filters.Filter `yaml:"deny-list"`
93
// Severity (optional) is the severity of the issue.
94
Severity []string `yaml:"severity" json:"severity"`
95
HttpClient *retryablehttp.Client `yaml:"-" json:"-"`
96
// for each customfield specified in the configuration options
97
// we will create a map of customfield name to the value
98
// that will be used to create the issue
99
CustomFields map[string]interface{} `yaml:"custom-fields" json:"custom_fields"`
100
StatusNot string `yaml:"status-not" json:"status_not"`
101
OmitRaw bool `yaml:"-"`
102
}
103
104
// New creates a new issue tracker integration client based on options.
105
func New(options *Options) (*Integration, error) {
106
username := options.Email
107
if !options.Cloud {
108
username = options.AccountID
109
}
110
111
var httpclient *http.Client
112
if options.PersonalAccessToken != "" {
113
bearerTp := jira.BearerAuthTransport{
114
Token: options.PersonalAccessToken,
115
}
116
if options.HttpClient != nil {
117
bearerTp.Transport = options.HttpClient.HTTPClient.Transport
118
}
119
httpclient = bearerTp.Client()
120
} else {
121
basicTp := jira.BasicAuthTransport{
122
Username: username,
123
Password: options.Token,
124
}
125
if options.HttpClient != nil {
126
basicTp.Transport = options.HttpClient.HTTPClient.Transport
127
}
128
httpclient = basicTp.Client()
129
}
130
131
jiraClient, err := jira.NewClient(httpclient, options.URL)
132
if err != nil {
133
return nil, err
134
}
135
integration := &Integration{
136
jira: jiraClient,
137
options: options,
138
once: &sync.Once{},
139
}
140
return integration, nil
141
}
142
143
func (i *Integration) Name() string {
144
return "jira"
145
}
146
147
// CreateNewIssue creates a new issue in the tracker
148
func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
149
summary := format.Summary(event)
150
labels := []string{}
151
severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())
152
if i.options.SeverityAsLabel && severityLabel != "" {
153
labels = append(labels, severityLabel)
154
}
155
if label := i.options.IssueType; label != "" {
156
labels = append(labels, label)
157
}
158
// for each custom value, take the name of the custom field and
159
// set the value of the custom field to the value specified in the
160
// configuration options
161
customFields := tcontainer.NewMarshalMap()
162
for name, value := range i.options.CustomFields {
163
//customFields[name] = map[string]interface{}{"value": value}
164
if valueMap, ok := value.(map[interface{}]interface{}); ok {
165
// Iterate over nested map
166
for nestedName, nestedValue := range valueMap {
167
fmtNestedValue, ok := nestedValue.(string)
168
if !ok {
169
return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue)
170
}
171
if strings.HasPrefix(fmtNestedValue, "$") {
172
nestedValue = strings.TrimPrefix(fmtNestedValue, "$")
173
switch nestedValue {
174
case "CVSSMetrics":
175
nestedValue = ptr.Safe(event.Info.Classification).CVSSMetrics
176
case "CVEID":
177
nestedValue = ptr.Safe(event.Info.Classification).CVEID
178
case "CWEID":
179
nestedValue = ptr.Safe(event.Info.Classification).CWEID
180
case "CVSSScore":
181
nestedValue = ptr.Safe(event.Info.Classification).CVSSScore
182
case "Host":
183
nestedValue = event.Host
184
case "Severity":
185
nestedValue = event.Info.SeverityHolder
186
case "Name":
187
nestedValue = event.Info.Name
188
}
189
}
190
switch nestedName {
191
case "id":
192
customFields[name] = map[string]interface{}{"id": nestedValue}
193
case "name":
194
customFields[name] = map[string]interface{}{"value": nestedValue}
195
case "freeform":
196
customFields[name] = nestedValue
197
}
198
}
199
}
200
}
201
fields := &jira.IssueFields{
202
Assignee: &jira.User{Name: i.options.AccountID},
203
Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
204
Unknowns: customFields,
205
Labels: labels,
206
Type: jira.IssueType{Name: i.options.IssueType},
207
Project: jira.Project{Key: i.options.ProjectName},
208
Summary: summary,
209
}
210
211
// On-prem version of Jira server does not use AccountID
212
if !i.options.Cloud {
213
fields = &jira.IssueFields{
214
Assignee: &jira.User{Name: i.options.AccountID},
215
Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
216
Type: jira.IssueType{Name: i.options.IssueType},
217
Project: jira.Project{Key: i.options.ProjectName},
218
Summary: summary,
219
Labels: labels,
220
Unknowns: customFields,
221
}
222
}
223
if i.options.IssueTypeID != "" {
224
fields.Type = jira.IssueType{ID: i.options.IssueTypeID}
225
}
226
if i.options.ProjectID != "" {
227
fields.Project = jira.Project{ID: i.options.ProjectID}
228
}
229
230
issueData := &jira.Issue{
231
Fields: fields,
232
}
233
createdIssue, resp, err := i.jira.Issue.Create(issueData)
234
if err != nil {
235
var data string
236
if resp != nil && resp.Body != nil {
237
d, _ := io.ReadAll(resp.Body)
238
data = string(d)
239
}
240
return nil, fmt.Errorf("%w => %s", err, data)
241
}
242
return getIssueResponseFromJira(createdIssue)
243
}
244
245
func getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) {
246
parsed, err := url.Parse(issue.Self)
247
if err != nil {
248
return nil, err
249
}
250
parsed.Path = fmt.Sprintf("/browse/%s", issue.Key)
251
issueURL := parsed.String()
252
253
return &filters.CreateIssueResponse{
254
IssueID: issue.ID,
255
IssueURL: issueURL,
256
}, nil
257
}
258
259
// CreateIssue creates an issue in the tracker or updates the existing one
260
func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
261
if i.options.UpdateExisting {
262
issue, err := i.FindExistingIssue(event, true)
263
if err != nil {
264
return nil, errors.Wrap(err, "could not find existing issue")
265
} else if issue.ID != "" {
266
_, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{
267
Body: format.CreateReportDescription(event, i, i.options.OmitRaw),
268
})
269
if err != nil {
270
return nil, errors.Wrap(err, "could not add comment to existing issue")
271
}
272
return getIssueResponseFromJira(&issue)
273
}
274
}
275
resp, err := i.CreateNewIssue(event)
276
if err != nil {
277
return nil, errors.Wrap(err, "could not create new issue")
278
}
279
return resp, nil
280
}
281
282
func (i *Integration) CloseIssue(event *output.ResultEvent) error {
283
if i.options.StatusNot == "" {
284
return nil
285
}
286
287
issue, err := i.FindExistingIssue(event, false)
288
if err != nil {
289
return err
290
} else if issue.ID != "" {
291
// Lazy load the transitions ID in case it's not set
292
i.once.Do(func() {
293
transitions, _, err := i.jira.Issue.GetTransitions(issue.ID)
294
if err != nil {
295
return
296
}
297
for _, transition := range transitions {
298
if transition.Name == i.options.StatusNot {
299
i.transitionID = transition.ID
300
break
301
}
302
}
303
})
304
if i.transitionID == "" {
305
return nil
306
}
307
transition := jira.CreateTransitionPayload{
308
Transition: jira.TransitionPayload{
309
ID: i.transitionID,
310
},
311
}
312
313
_, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition)
314
if err != nil {
315
return err
316
}
317
}
318
return nil
319
}
320
321
// FindExistingIssue checks if the issue already exists and returns its ID
322
func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {
323
template := format.GetMatchedTemplateName(event)
324
project := i.options.ProjectName
325
if i.options.ProjectID != "" {
326
project = i.options.ProjectID
327
}
328
jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)
329
if useStatus {
330
jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)
331
}
332
333
searchOptions := &jira.SearchOptions{
334
MaxResults: 1, // if any issue exists, then we won't create a new one
335
}
336
337
chunk, resp, err := i.jira.Issue.Search(jql, searchOptions)
338
if err != nil {
339
var data string
340
if resp != nil && resp.Body != nil {
341
d, _ := io.ReadAll(resp.Body)
342
data = string(d)
343
}
344
return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
345
}
346
347
switch resp.Total {
348
case 0:
349
return jira.Issue{}, nil
350
case 1:
351
return chunk[0], nil
352
default:
353
gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, chunk[0].ID)
354
return chunk[0], nil
355
}
356
}
357
358
// ShouldFilter determines if an issue should be logged to this tracker
359
func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {
360
if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {
361
return false
362
}
363
364
if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {
365
return false
366
}
367
368
return true
369
}
370
371