Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions pkg/reporting/trackers/jira/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,45 @@ func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus boo
jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)
}

// Hotfix for Jira Cloud: use Enhanced Search API (v3) to avoid deprecated v2 path
if i.options.Cloud {
params := url.Values{}
params.Set("jql", jql)
params.Set("maxResults", "1")
params.Set("fields", "id,key")

req, err := i.jira.NewRequest("GET", "/rest/api/3/search/jql"+"?"+params.Encode(), nil)
if err != nil {
return jira.Issue{}, err
}

var searchResult struct {
Total int `json:"total"`
Issues []struct {
ID string `json:"id"`
Key string `json:"key"`
} `json:"issues"`
}

resp, err := i.jira.Do(req, &searchResult)
if err != nil {
var data string
if resp != nil && resp.Body != nil {
d, _ := io.ReadAll(resp.Body)
data = string(d)
}
return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
}

switch searchResult.Total {
case 0:
return jira.Issue{}, nil
default:
first := searchResult.Issues[0]
return jira.Issue{ID: first.ID, Key: first.Key}, nil
}
}
Comment on lines +440 to +481

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Verify the Cloud path covers all edge cases from the v2 path.

The new Cloud-specific path using Enhanced Search API v3 looks correct. However, ensure it handles all scenarios that the existing v2 path handles, particularly around multiple results and logging behavior.

The v2 path (lines 498-506) has specific handling for multiple results with a warning message. The Cloud path returns only the first result but doesn't replicate this warning behavior. Run the following script to verify if multiple result handling is important:


🏁 Script executed:

#!/bin/bash
# Description: Check if multiple result handling logic is referenced elsewhere in the codebase

# Search for references to the warning message about multiple opened issues
rg -n "Discovered multiple opened issues" --type=go

# Search for any tests or documentation that might rely on this behavior
rg -n "multiple.*issue.*opened\|opened.*issue.*multiple" --type=go -C3

Length of output: 190


Replicate v2's multiple-results warning in the Cloud (v3) path

v2 logs "Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated." at pkg/reporting/trackers/jira/jira.go:504; the Cloud branch (pkg/reporting/trackers/jira/jira.go — lines ~440–481) currently returns the first issue silently. When len(searchResult.Issues) > 1, emit the same gologger.Warning().Msgf(...) or otherwise handle multiple results identically.

🤖 Prompt for AI Agents
In pkg/reporting/trackers/jira/jira.go around lines 440 to 481, the Cloud (v3)
branch currently returns the first found issue silently; replicate v2's behavior
by emitting the same multiple-results warning when len(searchResult.Issues) > 1:
build a comma-separated string of all issue keys from searchResult.Issues, then
call gologger.Warning().Msgf("Discovered multiple opened issues %s for the host
%s: The issue [%s] will be updated.", keysJoined, <hostIdentifierUsedInV2>,
first.Key) before returning the first issue (use the same host identifier
variable used in the v2 code path and ensure gologger is imported if not
present).


searchOptions := &jira.SearchOptionsV2{
MaxResults: 1, // if any issue exists, then we won't create a new one
Fields: []string{"summary", "description", "issuetype", "status", "priority", "project"},
Expand Down
83 changes: 83 additions & 0 deletions pkg/reporting/trackers/jira/jira_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package jira

import (
"net/http"
"os"
"strings"
"testing"

Expand All @@ -9,9 +11,23 @@
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/stretchr/testify/require"
)

type recordingTransport struct {
inner http.RoundTripper
paths []string
}

func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.inner == nil {
rt.inner = http.DefaultTransport
}
rt.paths = append(rt.paths, req.URL.Path)
return rt.inner.RoundTrip(req)
}

func TestLinkCreation(t *testing.T) {
jiraIntegration := &Integration{}
link := jiraIntegration.CreateLink("ProjectDiscovery", "https://projectdiscovery.io")
Expand Down Expand Up @@ -188,3 +204,70 @@
require.Contains(t, result, "CRITICAL")
})
}

// Live test to verify SearchV2JQL hits /rest/api/3/search/jql when creds are provided via env
func TestJiraLive_SearchV2UsesJqlEndpoint(t *testing.T) {
jiraURL := os.Getenv("JIRA_URL")
jiraEmail := os.Getenv("JIRA_EMAIL")
jiraAccountID := os.Getenv("JIRA_ACCOUNT_ID")
jiraToken := os.Getenv("JIRA_TOKEN")
jiraPAT := os.Getenv("JIRA_PAT")
jiraProjectName := os.Getenv("JIRA_PROJECT_NAME")
jiraProjectID := os.Getenv("JIRA_PROJECT_ID")
jiraStatusNot := os.Getenv("JIRA_STATUS_NOT")
jiraCloud := os.Getenv("JIRA_CLOUD")

if jiraURL == "" || (jiraPAT == "" && jiraToken == "") || (jiraEmail == "" && jiraAccountID == "") || (jiraProjectName == "" && jiraProjectID == "") {
t.Skip("live Jira test skipped: missing JIRA_* env vars")
}

statusNot := jiraStatusNot
if statusNot == "" {
statusNot = "Done"
}

isCloud := true

Check failure on line 229 in pkg/reporting/trackers/jira/jira_test.go

View workflow job for this annotation

GitHub Actions / Lint

QF1007: could merge conditional assignment into variable declaration (staticcheck)
if strings.EqualFold(jiraCloud, "false") || jiraCloud == "0" {
isCloud = false
}

rec := &recordingTransport{}
rc := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
rc.HTTPClient.Transport = rec

opts := &Options{
Cloud: isCloud,
URL: jiraURL,
Email: jiraEmail,
AccountID: jiraAccountID,
Token: jiraToken,
PersonalAccessToken: jiraPAT,
ProjectName: jiraProjectName,
ProjectID: jiraProjectID,
IssueType: "Task",
StatusNot: statusNot,
HttpClient: rc,
}

integration, err := New(opts)
require.NoError(t, err)

event := &output.ResultEvent{
Host: "example.com",
Info: model.Info{
Name: "Nuclei Live Verify",
SeverityHolder: severity.Holder{Severity: severity.Low},
},
}

_, _ = integration.FindExistingIssue(event, true)

var hitSearchV2 bool
for _, p := range rec.paths {
if strings.HasSuffix(p, "/rest/api/3/search/jql") {
hitSearchV2 = true
break
}
}
require.True(t, hitSearchV2, "expected client to call /rest/api/3/search/jql, got paths: %v", rec.paths)
}
Loading