Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
43 changes: 43 additions & 0 deletions pkg/reporting/trackers/jira/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,49 @@ 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 {
Issues []struct {
ID string `json:"id"`
Key string `json:"key"`
} `json:"issues"`
IsLast bool `json:"isLast"`
NextPageToken string `json:"nextPageToken"`
}

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)
}

if len(searchResult.Issues) == 0 {
return jira.Issue{}, nil
}
first := searchResult.Issues[0]
base := strings.TrimRight(i.options.URL, "/")
return jira.Issue{
ID: first.ID,
Key: first.Key,
Self: fmt.Sprintf("%s/rest/api/3/issue/%s", base, first.ID),
}, nil
}
Comment on lines +440 to +481
Copy link
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
80 changes: 80 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 @@ import (
"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,67 @@ Priority: {{if eq .Severity "critical"}}{{.Severity | upper}}{{else}}{{.Severity
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 := !strings.EqualFold(jiraCloud, "false") && jiraCloud != "0"

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