Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
projectdiscovery
GitHub Repository: projectdiscovery/nuclei
Path: blob/dev/pkg/reporting/trackers/jira/jira.go
2869 views
1
package jira
2
3
import (
4
"bytes"
5
"fmt"
6
"io"
7
"net/http"
8
"net/url"
9
"strings"
10
"sync"
11
"text/template"
12
13
"github.com/andygrunwald/go-jira"
14
"github.com/pkg/errors"
15
"github.com/trivago/tgo/tcontainer"
16
"golang.org/x/text/cases"
17
"golang.org/x/text/language"
18
19
"github.com/projectdiscovery/gologger"
20
"github.com/projectdiscovery/nuclei/v3/pkg/output"
21
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
22
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"
23
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
24
"github.com/projectdiscovery/retryablehttp-go"
25
"github.com/projectdiscovery/utils/ptr"
26
)
27
28
type Formatter struct {
29
util.MarkdownFormatter
30
}
31
32
// TemplateContext holds the data available for template evaluation
33
type TemplateContext struct {
34
Severity string
35
Name string
36
Host string
37
CVSSScore string
38
CVEID string
39
CWEID string
40
CVSSMetrics string
41
Tags []string
42
}
43
44
// buildTemplateContext creates a template context from a ResultEvent
45
func buildTemplateContext(event *output.ResultEvent) *TemplateContext {
46
ctx := &TemplateContext{
47
Host: event.Host,
48
Name: event.Info.Name,
49
Tags: event.Info.Tags.ToSlice(),
50
}
51
52
// Set severity string
53
ctx.Severity = event.Info.SeverityHolder.Severity.String()
54
55
if event.Info.Classification != nil {
56
ctx.CVSSScore = fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore)
57
ctx.CVEID = strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", ")
58
ctx.CWEID = strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", ")
59
ctx.CVSSMetrics = ptr.Safe(event.Info.Classification).CVSSMetrics
60
}
61
62
return ctx
63
}
64
65
// evaluateTemplate executes a template string with the given context
66
func evaluateTemplate(templateStr string, ctx *TemplateContext) (string, error) {
67
// If no template markers found, return as-is for backward compatibility
68
if !strings.Contains(templateStr, "{{") {
69
return templateStr, nil
70
}
71
72
// Create template with useful functions for JIRA custom fields
73
funcMap := template.FuncMap{
74
"upper": strings.ToUpper,
75
"lower": strings.ToLower,
76
"title": cases.Title(language.English).String,
77
"contains": strings.Contains,
78
"hasPrefix": strings.HasPrefix,
79
"hasSuffix": strings.HasSuffix,
80
"trim": strings.Trim,
81
"trimSpace": strings.TrimSpace,
82
"replace": strings.ReplaceAll,
83
"split": strings.Split,
84
"join": strings.Join,
85
}
86
87
tmpl, err := template.New("field").Funcs(funcMap).Parse(templateStr)
88
if err != nil {
89
return templateStr, fmt.Errorf("failed to parse template: %w", err)
90
}
91
92
var buf bytes.Buffer
93
if err := tmpl.Execute(&buf, ctx); err != nil {
94
return templateStr, fmt.Errorf("failed to execute template: %w", err)
95
}
96
97
return buf.String(), nil
98
}
99
100
// evaluateCustomFieldValue evaluates a custom field value, supporting both new template syntax and legacy $variable syntax
101
func (i *Integration) evaluateCustomFieldValue(value string, templateCtx *TemplateContext, event *output.ResultEvent) (interface{}, error) {
102
// Try template evaluation first (supports {{...}} syntax)
103
if strings.Contains(value, "{{") {
104
return evaluateTemplate(value, templateCtx)
105
}
106
107
// Handle legacy $variable syntax for backward compatibility
108
if strings.HasPrefix(value, "$") {
109
variableName := strings.TrimPrefix(value, "$")
110
switch variableName {
111
case "CVSSMetrics":
112
if event.Info.Classification != nil {
113
return ptr.Safe(event.Info.Classification).CVSSMetrics, nil
114
}
115
return "", nil
116
case "CVEID":
117
if event.Info.Classification != nil {
118
return strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", "), nil
119
}
120
return "", nil
121
case "CWEID":
122
if event.Info.Classification != nil {
123
return strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", "), nil
124
}
125
return "", nil
126
case "CVSSScore":
127
if event.Info.Classification != nil {
128
return fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore), nil
129
}
130
return "", nil
131
case "Host":
132
return event.Host, nil
133
case "Severity":
134
return event.Info.SeverityHolder.Severity.String(), nil
135
case "Name":
136
return event.Info.Name, nil
137
default:
138
return value, nil // return as-is if variable not found
139
}
140
}
141
142
// Return as-is if no template or variable syntax found
143
return value, nil
144
}
145
146
func (jiraFormatter *Formatter) MakeBold(text string) string {
147
return "*" + text + "*"
148
}
149
150
func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {
151
escapedContent := strings.ReplaceAll(content, "{code}", "")
152
return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)
153
}
154
155
func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {
156
table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)
157
if err != nil {
158
return "", err
159
}
160
tableRows := strings.Split(table, "\n")
161
tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)
162
return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil
163
}
164
165
func (jiraFormatter *Formatter) CreateLink(title string, url string) string {
166
return fmt.Sprintf("[%s|%s]", title, url)
167
}
168
169
// Integration is a client for an issue tracker integration
170
type Integration struct {
171
Formatter
172
jira *jira.Client
173
options *Options
174
175
once *sync.Once
176
transitionID string
177
}
178
179
// Options contains the configuration options for jira client
180
type Options struct {
181
// Cloud value (optional) is set to true when Jira cloud is used
182
Cloud bool `yaml:"cloud" json:"cloud"`
183
// UpdateExisting value (optional) if true, the existing opened issue is updated
184
UpdateExisting bool `yaml:"update-existing" json:"update_existing"`
185
// URL is the URL of the jira server
186
URL string `yaml:"url" json:"url" validate:"required"`
187
// SiteURL is the browsable URL for the Jira instance (optional)
188
// If not provided, issue.Self will be used. Useful for OAuth where issue.Self contains api.atlassian.com
189
SiteURL string `yaml:"site-url" json:"site_url"`
190
// AccountID is the accountID of the jira user.
191
AccountID string `yaml:"account-id" json:"account_id" validate:"required"`
192
// Email is the email of the user for jira instance
193
Email string `yaml:"email" json:"email"`
194
// PersonalAccessToken is the personal access token for jira instance.
195
// If this is set, Bearer Auth is used instead of Basic Auth.
196
PersonalAccessToken string `yaml:"personal-access-token" json:"personal_access_token"`
197
// Token is the token for jira instance.
198
Token string `yaml:"token" json:"token"`
199
// ProjectName is the name of the project.
200
ProjectName string `yaml:"project-name" json:"project_name"`
201
// ProjectID is the ID of the project (optional)
202
ProjectID string `yaml:"project-id" json:"project_id"`
203
// IssueType (optional) is the name of the created issue type
204
IssueType string `yaml:"issue-type" json:"issue_type"`
205
// IssueTypeID (optional) is the ID of the created issue type
206
IssueTypeID string `yaml:"issue-type-id" json:"issue_type_id"`
207
// SeverityAsLabel (optional) sends the severity as the label of the created
208
// issue.
209
SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`
210
// AllowList contains a list of allowed events for this tracker
211
AllowList *filters.Filter `yaml:"allow-list"`
212
// DenyList contains a list of denied events for this tracker
213
DenyList *filters.Filter `yaml:"deny-list"`
214
// Severity (optional) is the severity of the issue.
215
Severity []string `yaml:"severity" json:"severity"`
216
HttpClient *retryablehttp.Client `yaml:"-" json:"-"`
217
// for each customfield specified in the configuration options
218
// we will create a map of customfield name to the value
219
// that will be used to create the issue
220
CustomFields map[string]interface{} `yaml:"custom-fields" json:"custom_fields"`
221
StatusNot string `yaml:"status-not" json:"status_not"`
222
OmitRaw bool `yaml:"-"`
223
}
224
225
// New creates a new issue tracker integration client based on options.
226
func New(options *Options) (*Integration, error) {
227
username := options.Email
228
if !options.Cloud {
229
username = options.AccountID
230
}
231
232
var httpclient *http.Client
233
if options.PersonalAccessToken != "" {
234
bearerTp := jira.BearerAuthTransport{
235
Token: options.PersonalAccessToken,
236
}
237
if options.HttpClient != nil {
238
bearerTp.Transport = options.HttpClient.HTTPClient.Transport
239
}
240
httpclient = bearerTp.Client()
241
} else {
242
basicTp := jira.BasicAuthTransport{
243
Username: username,
244
Password: options.Token,
245
}
246
if options.HttpClient != nil {
247
basicTp.Transport = options.HttpClient.HTTPClient.Transport
248
}
249
httpclient = basicTp.Client()
250
}
251
252
jiraClient, err := jira.NewClient(httpclient, options.URL)
253
if err != nil {
254
return nil, err
255
}
256
integration := &Integration{
257
jira: jiraClient,
258
options: options,
259
once: &sync.Once{},
260
}
261
return integration, nil
262
}
263
264
func (i *Integration) Name() string {
265
return "jira"
266
}
267
268
// CreateNewIssue creates a new issue in the tracker
269
func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
270
summary := format.Summary(event)
271
labels := []string{}
272
severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())
273
if i.options.SeverityAsLabel && severityLabel != "" {
274
labels = append(labels, severityLabel)
275
}
276
if label := i.options.IssueType; label != "" {
277
labels = append(labels, label)
278
}
279
// Build template context for evaluating custom field templates
280
templateCtx := buildTemplateContext(event)
281
282
// Process custom fields with template evaluation support
283
customFields := tcontainer.NewMarshalMap()
284
for name, value := range i.options.CustomFields {
285
if valueMap, ok := value.(map[interface{}]interface{}); ok {
286
// Iterate over nested map
287
for nestedName, nestedValue := range valueMap {
288
fmtNestedValue, ok := nestedValue.(string)
289
if !ok {
290
return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue)
291
}
292
293
// Evaluate template or handle legacy $variable syntax
294
evaluatedValue, err := i.evaluateCustomFieldValue(fmtNestedValue, templateCtx, event)
295
if err != nil {
296
gologger.Warning().Msgf("Failed to evaluate template for field %s.%s: %v", name, nestedName, err)
297
evaluatedValue = fmtNestedValue // fallback to original value
298
}
299
300
switch nestedName {
301
case "id":
302
customFields[name] = map[string]interface{}{"id": evaluatedValue}
303
case "name":
304
customFields[name] = map[string]interface{}{"value": evaluatedValue}
305
case "freeform":
306
customFields[name] = evaluatedValue
307
}
308
}
309
}
310
}
311
fields := &jira.IssueFields{
312
Assignee: &jira.User{Name: i.options.AccountID},
313
Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
314
Unknowns: customFields,
315
Labels: labels,
316
Type: jira.IssueType{Name: i.options.IssueType},
317
Project: jira.Project{Key: i.options.ProjectName},
318
Summary: summary,
319
}
320
321
// On-prem version of Jira server does not use AccountID
322
if !i.options.Cloud {
323
fields = &jira.IssueFields{
324
Assignee: &jira.User{Name: i.options.AccountID},
325
Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
326
Type: jira.IssueType{Name: i.options.IssueType},
327
Project: jira.Project{Key: i.options.ProjectName},
328
Summary: summary,
329
Labels: labels,
330
Unknowns: customFields,
331
}
332
}
333
if i.options.IssueTypeID != "" {
334
fields.Type = jira.IssueType{ID: i.options.IssueTypeID}
335
}
336
if i.options.ProjectID != "" {
337
fields.Project = jira.Project{ID: i.options.ProjectID}
338
}
339
340
issueData := &jira.Issue{
341
Fields: fields,
342
}
343
createdIssue, resp, err := i.jira.Issue.Create(issueData)
344
if err != nil {
345
var data string
346
if resp != nil && resp.Body != nil {
347
d, _ := io.ReadAll(resp.Body)
348
data = string(d)
349
}
350
return nil, fmt.Errorf("%w => %s", err, data)
351
}
352
return i.getIssueResponseFromJira(createdIssue)
353
}
354
355
func (i *Integration) getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) {
356
var issueURL string
357
358
// Use SiteURL if provided, otherwise fall back to original issue.Self logic
359
if i.options.SiteURL != "" {
360
// Use the configured site URL for browsable links (useful for OAuth)
361
baseURL := strings.TrimRight(i.options.SiteURL, "/")
362
issueURL = fmt.Sprintf("%s/browse/%s", baseURL, issue.Key)
363
} else {
364
// Fall back to original logic using issue.Self
365
parsed, err := url.Parse(issue.Self)
366
if err != nil {
367
return nil, err
368
}
369
parsed.Path = fmt.Sprintf("/browse/%s", issue.Key)
370
issueURL = parsed.String()
371
}
372
373
return &filters.CreateIssueResponse{
374
IssueID: issue.ID,
375
IssueURL: issueURL,
376
}, nil
377
}
378
379
// CreateIssue creates an issue in the tracker or updates the existing one
380
func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
381
if i.options.UpdateExisting {
382
issue, err := i.FindExistingIssue(event, true)
383
if err != nil {
384
return nil, errors.Wrap(err, "could not find existing issue")
385
} else if issue.ID != "" {
386
_, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{
387
Body: format.CreateReportDescription(event, i, i.options.OmitRaw),
388
})
389
if err != nil {
390
return nil, errors.Wrap(err, "could not add comment to existing issue")
391
}
392
return i.getIssueResponseFromJira(&issue)
393
}
394
}
395
resp, err := i.CreateNewIssue(event)
396
if err != nil {
397
return nil, errors.Wrap(err, "could not create new issue")
398
}
399
return resp, nil
400
}
401
402
func (i *Integration) CloseIssue(event *output.ResultEvent) error {
403
if i.options.StatusNot == "" {
404
return nil
405
}
406
407
issue, err := i.FindExistingIssue(event, false)
408
if err != nil {
409
return err
410
} else if issue.ID != "" {
411
// Lazy load the transitions ID in case it's not set
412
i.once.Do(func() {
413
transitions, _, err := i.jira.Issue.GetTransitions(issue.ID)
414
if err != nil {
415
return
416
}
417
for _, transition := range transitions {
418
if transition.Name == i.options.StatusNot {
419
i.transitionID = transition.ID
420
break
421
}
422
}
423
})
424
if i.transitionID == "" {
425
return nil
426
}
427
transition := jira.CreateTransitionPayload{
428
Transition: jira.TransitionPayload{
429
ID: i.transitionID,
430
},
431
}
432
433
_, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition)
434
if err != nil {
435
return err
436
}
437
}
438
return nil
439
}
440
441
// FindExistingIssue checks if the issue already exists and returns its ID
442
func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {
443
template := format.GetMatchedTemplateName(event)
444
project := i.options.ProjectName
445
if i.options.ProjectID != "" {
446
project = i.options.ProjectID
447
}
448
jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)
449
if useStatus {
450
jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)
451
}
452
453
// Hotfix for Jira Cloud: use Enhanced Search API (v3) to avoid deprecated v2 path
454
if i.options.Cloud {
455
params := url.Values{}
456
params.Set("jql", jql)
457
params.Set("maxResults", "1")
458
params.Set("fields", "id,key")
459
460
req, err := i.jira.NewRequest("GET", "/rest/api/3/search/jql"+"?"+params.Encode(), nil)
461
if err != nil {
462
return jira.Issue{}, err
463
}
464
465
var searchResult struct {
466
Issues []struct {
467
ID string `json:"id"`
468
Key string `json:"key"`
469
} `json:"issues"`
470
IsLast bool `json:"isLast"`
471
NextPageToken string `json:"nextPageToken"`
472
}
473
474
resp, err := i.jira.Do(req, &searchResult)
475
if err != nil {
476
var data string
477
if resp != nil && resp.Body != nil {
478
d, _ := io.ReadAll(resp.Body)
479
data = string(d)
480
}
481
return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
482
}
483
484
if len(searchResult.Issues) == 0 {
485
return jira.Issue{}, nil
486
}
487
first := searchResult.Issues[0]
488
base := strings.TrimRight(i.options.URL, "/")
489
return jira.Issue{
490
ID: first.ID,
491
Key: first.Key,
492
Self: fmt.Sprintf("%s/rest/api/3/issue/%s", base, first.ID),
493
}, nil
494
}
495
496
searchOptions := &jira.SearchOptionsV2{
497
MaxResults: 1, // if any issue exists, then we won't create a new one
498
Fields: []string{"summary", "description", "issuetype", "status", "priority", "project"},
499
}
500
501
issues, resp, err := i.jira.Issue.SearchV2JQL(jql, searchOptions)
502
if err != nil {
503
var data string
504
if resp != nil && resp.Body != nil {
505
d, _ := io.ReadAll(resp.Body)
506
data = string(d)
507
}
508
return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
509
}
510
511
switch resp.Total {
512
case 0:
513
return jira.Issue{}, nil
514
case 1:
515
return issues[0], nil
516
default:
517
gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, issues[0].ID)
518
return issues[0], nil
519
}
520
}
521
522
// ShouldFilter determines if an issue should be logged to this tracker
523
func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {
524
if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {
525
return false
526
}
527
528
if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {
529
return false
530
}
531
532
return true
533
}
534
535