Path: blob/dev/pkg/reporting/trackers/jira/jira.go
2869 views
package jira12import (3"bytes"4"fmt"5"io"6"net/http"7"net/url"8"strings"9"sync"10"text/template"1112"github.com/andygrunwald/go-jira"13"github.com/pkg/errors"14"github.com/trivago/tgo/tcontainer"15"golang.org/x/text/cases"16"golang.org/x/text/language"1718"github.com/projectdiscovery/gologger"19"github.com/projectdiscovery/nuclei/v3/pkg/output"20"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"21"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"22"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"23"github.com/projectdiscovery/retryablehttp-go"24"github.com/projectdiscovery/utils/ptr"25)2627type Formatter struct {28util.MarkdownFormatter29}3031// TemplateContext holds the data available for template evaluation32type TemplateContext struct {33Severity string34Name string35Host string36CVSSScore string37CVEID string38CWEID string39CVSSMetrics string40Tags []string41}4243// buildTemplateContext creates a template context from a ResultEvent44func buildTemplateContext(event *output.ResultEvent) *TemplateContext {45ctx := &TemplateContext{46Host: event.Host,47Name: event.Info.Name,48Tags: event.Info.Tags.ToSlice(),49}5051// Set severity string52ctx.Severity = event.Info.SeverityHolder.Severity.String()5354if event.Info.Classification != nil {55ctx.CVSSScore = fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore)56ctx.CVEID = strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", ")57ctx.CWEID = strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", ")58ctx.CVSSMetrics = ptr.Safe(event.Info.Classification).CVSSMetrics59}6061return ctx62}6364// evaluateTemplate executes a template string with the given context65func evaluateTemplate(templateStr string, ctx *TemplateContext) (string, error) {66// If no template markers found, return as-is for backward compatibility67if !strings.Contains(templateStr, "{{") {68return templateStr, nil69}7071// Create template with useful functions for JIRA custom fields72funcMap := template.FuncMap{73"upper": strings.ToUpper,74"lower": strings.ToLower,75"title": cases.Title(language.English).String,76"contains": strings.Contains,77"hasPrefix": strings.HasPrefix,78"hasSuffix": strings.HasSuffix,79"trim": strings.Trim,80"trimSpace": strings.TrimSpace,81"replace": strings.ReplaceAll,82"split": strings.Split,83"join": strings.Join,84}8586tmpl, err := template.New("field").Funcs(funcMap).Parse(templateStr)87if err != nil {88return templateStr, fmt.Errorf("failed to parse template: %w", err)89}9091var buf bytes.Buffer92if err := tmpl.Execute(&buf, ctx); err != nil {93return templateStr, fmt.Errorf("failed to execute template: %w", err)94}9596return buf.String(), nil97}9899// evaluateCustomFieldValue evaluates a custom field value, supporting both new template syntax and legacy $variable syntax100func (i *Integration) evaluateCustomFieldValue(value string, templateCtx *TemplateContext, event *output.ResultEvent) (interface{}, error) {101// Try template evaluation first (supports {{...}} syntax)102if strings.Contains(value, "{{") {103return evaluateTemplate(value, templateCtx)104}105106// Handle legacy $variable syntax for backward compatibility107if strings.HasPrefix(value, "$") {108variableName := strings.TrimPrefix(value, "$")109switch variableName {110case "CVSSMetrics":111if event.Info.Classification != nil {112return ptr.Safe(event.Info.Classification).CVSSMetrics, nil113}114return "", nil115case "CVEID":116if event.Info.Classification != nil {117return strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", "), nil118}119return "", nil120case "CWEID":121if event.Info.Classification != nil {122return strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", "), nil123}124return "", nil125case "CVSSScore":126if event.Info.Classification != nil {127return fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore), nil128}129return "", nil130case "Host":131return event.Host, nil132case "Severity":133return event.Info.SeverityHolder.Severity.String(), nil134case "Name":135return event.Info.Name, nil136default:137return value, nil // return as-is if variable not found138}139}140141// Return as-is if no template or variable syntax found142return value, nil143}144145func (jiraFormatter *Formatter) MakeBold(text string) string {146return "*" + text + "*"147}148149func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {150escapedContent := strings.ReplaceAll(content, "{code}", "")151return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)152}153154func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {155table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)156if err != nil {157return "", err158}159tableRows := strings.Split(table, "\n")160tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)161return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil162}163164func (jiraFormatter *Formatter) CreateLink(title string, url string) string {165return fmt.Sprintf("[%s|%s]", title, url)166}167168// Integration is a client for an issue tracker integration169type Integration struct {170Formatter171jira *jira.Client172options *Options173174once *sync.Once175transitionID string176}177178// Options contains the configuration options for jira client179type Options struct {180// Cloud value (optional) is set to true when Jira cloud is used181Cloud bool `yaml:"cloud" json:"cloud"`182// UpdateExisting value (optional) if true, the existing opened issue is updated183UpdateExisting bool `yaml:"update-existing" json:"update_existing"`184// URL is the URL of the jira server185URL string `yaml:"url" json:"url" validate:"required"`186// SiteURL is the browsable URL for the Jira instance (optional)187// If not provided, issue.Self will be used. Useful for OAuth where issue.Self contains api.atlassian.com188SiteURL string `yaml:"site-url" json:"site_url"`189// AccountID is the accountID of the jira user.190AccountID string `yaml:"account-id" json:"account_id" validate:"required"`191// Email is the email of the user for jira instance192Email string `yaml:"email" json:"email"`193// PersonalAccessToken is the personal access token for jira instance.194// If this is set, Bearer Auth is used instead of Basic Auth.195PersonalAccessToken string `yaml:"personal-access-token" json:"personal_access_token"`196// Token is the token for jira instance.197Token string `yaml:"token" json:"token"`198// ProjectName is the name of the project.199ProjectName string `yaml:"project-name" json:"project_name"`200// ProjectID is the ID of the project (optional)201ProjectID string `yaml:"project-id" json:"project_id"`202// IssueType (optional) is the name of the created issue type203IssueType string `yaml:"issue-type" json:"issue_type"`204// IssueTypeID (optional) is the ID of the created issue type205IssueTypeID string `yaml:"issue-type-id" json:"issue_type_id"`206// SeverityAsLabel (optional) sends the severity as the label of the created207// issue.208SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`209// AllowList contains a list of allowed events for this tracker210AllowList *filters.Filter `yaml:"allow-list"`211// DenyList contains a list of denied events for this tracker212DenyList *filters.Filter `yaml:"deny-list"`213// Severity (optional) is the severity of the issue.214Severity []string `yaml:"severity" json:"severity"`215HttpClient *retryablehttp.Client `yaml:"-" json:"-"`216// for each customfield specified in the configuration options217// we will create a map of customfield name to the value218// that will be used to create the issue219CustomFields map[string]interface{} `yaml:"custom-fields" json:"custom_fields"`220StatusNot string `yaml:"status-not" json:"status_not"`221OmitRaw bool `yaml:"-"`222}223224// New creates a new issue tracker integration client based on options.225func New(options *Options) (*Integration, error) {226username := options.Email227if !options.Cloud {228username = options.AccountID229}230231var httpclient *http.Client232if options.PersonalAccessToken != "" {233bearerTp := jira.BearerAuthTransport{234Token: options.PersonalAccessToken,235}236if options.HttpClient != nil {237bearerTp.Transport = options.HttpClient.HTTPClient.Transport238}239httpclient = bearerTp.Client()240} else {241basicTp := jira.BasicAuthTransport{242Username: username,243Password: options.Token,244}245if options.HttpClient != nil {246basicTp.Transport = options.HttpClient.HTTPClient.Transport247}248httpclient = basicTp.Client()249}250251jiraClient, err := jira.NewClient(httpclient, options.URL)252if err != nil {253return nil, err254}255integration := &Integration{256jira: jiraClient,257options: options,258once: &sync.Once{},259}260return integration, nil261}262263func (i *Integration) Name() string {264return "jira"265}266267// CreateNewIssue creates a new issue in the tracker268func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {269summary := format.Summary(event)270labels := []string{}271severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())272if i.options.SeverityAsLabel && severityLabel != "" {273labels = append(labels, severityLabel)274}275if label := i.options.IssueType; label != "" {276labels = append(labels, label)277}278// Build template context for evaluating custom field templates279templateCtx := buildTemplateContext(event)280281// Process custom fields with template evaluation support282customFields := tcontainer.NewMarshalMap()283for name, value := range i.options.CustomFields {284if valueMap, ok := value.(map[interface{}]interface{}); ok {285// Iterate over nested map286for nestedName, nestedValue := range valueMap {287fmtNestedValue, ok := nestedValue.(string)288if !ok {289return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue)290}291292// Evaluate template or handle legacy $variable syntax293evaluatedValue, err := i.evaluateCustomFieldValue(fmtNestedValue, templateCtx, event)294if err != nil {295gologger.Warning().Msgf("Failed to evaluate template for field %s.%s: %v", name, nestedName, err)296evaluatedValue = fmtNestedValue // fallback to original value297}298299switch nestedName {300case "id":301customFields[name] = map[string]interface{}{"id": evaluatedValue}302case "name":303customFields[name] = map[string]interface{}{"value": evaluatedValue}304case "freeform":305customFields[name] = evaluatedValue306}307}308}309}310fields := &jira.IssueFields{311Assignee: &jira.User{Name: i.options.AccountID},312Description: format.CreateReportDescription(event, i, i.options.OmitRaw),313Unknowns: customFields,314Labels: labels,315Type: jira.IssueType{Name: i.options.IssueType},316Project: jira.Project{Key: i.options.ProjectName},317Summary: summary,318}319320// On-prem version of Jira server does not use AccountID321if !i.options.Cloud {322fields = &jira.IssueFields{323Assignee: &jira.User{Name: i.options.AccountID},324Description: format.CreateReportDescription(event, i, i.options.OmitRaw),325Type: jira.IssueType{Name: i.options.IssueType},326Project: jira.Project{Key: i.options.ProjectName},327Summary: summary,328Labels: labels,329Unknowns: customFields,330}331}332if i.options.IssueTypeID != "" {333fields.Type = jira.IssueType{ID: i.options.IssueTypeID}334}335if i.options.ProjectID != "" {336fields.Project = jira.Project{ID: i.options.ProjectID}337}338339issueData := &jira.Issue{340Fields: fields,341}342createdIssue, resp, err := i.jira.Issue.Create(issueData)343if err != nil {344var data string345if resp != nil && resp.Body != nil {346d, _ := io.ReadAll(resp.Body)347data = string(d)348}349return nil, fmt.Errorf("%w => %s", err, data)350}351return i.getIssueResponseFromJira(createdIssue)352}353354func (i *Integration) getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) {355var issueURL string356357// Use SiteURL if provided, otherwise fall back to original issue.Self logic358if i.options.SiteURL != "" {359// Use the configured site URL for browsable links (useful for OAuth)360baseURL := strings.TrimRight(i.options.SiteURL, "/")361issueURL = fmt.Sprintf("%s/browse/%s", baseURL, issue.Key)362} else {363// Fall back to original logic using issue.Self364parsed, err := url.Parse(issue.Self)365if err != nil {366return nil, err367}368parsed.Path = fmt.Sprintf("/browse/%s", issue.Key)369issueURL = parsed.String()370}371372return &filters.CreateIssueResponse{373IssueID: issue.ID,374IssueURL: issueURL,375}, nil376}377378// CreateIssue creates an issue in the tracker or updates the existing one379func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {380if i.options.UpdateExisting {381issue, err := i.FindExistingIssue(event, true)382if err != nil {383return nil, errors.Wrap(err, "could not find existing issue")384} else if issue.ID != "" {385_, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{386Body: format.CreateReportDescription(event, i, i.options.OmitRaw),387})388if err != nil {389return nil, errors.Wrap(err, "could not add comment to existing issue")390}391return i.getIssueResponseFromJira(&issue)392}393}394resp, err := i.CreateNewIssue(event)395if err != nil {396return nil, errors.Wrap(err, "could not create new issue")397}398return resp, nil399}400401func (i *Integration) CloseIssue(event *output.ResultEvent) error {402if i.options.StatusNot == "" {403return nil404}405406issue, err := i.FindExistingIssue(event, false)407if err != nil {408return err409} else if issue.ID != "" {410// Lazy load the transitions ID in case it's not set411i.once.Do(func() {412transitions, _, err := i.jira.Issue.GetTransitions(issue.ID)413if err != nil {414return415}416for _, transition := range transitions {417if transition.Name == i.options.StatusNot {418i.transitionID = transition.ID419break420}421}422})423if i.transitionID == "" {424return nil425}426transition := jira.CreateTransitionPayload{427Transition: jira.TransitionPayload{428ID: i.transitionID,429},430}431432_, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition)433if err != nil {434return err435}436}437return nil438}439440// FindExistingIssue checks if the issue already exists and returns its ID441func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {442template := format.GetMatchedTemplateName(event)443project := i.options.ProjectName444if i.options.ProjectID != "" {445project = i.options.ProjectID446}447jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)448if useStatus {449jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)450}451452// Hotfix for Jira Cloud: use Enhanced Search API (v3) to avoid deprecated v2 path453if i.options.Cloud {454params := url.Values{}455params.Set("jql", jql)456params.Set("maxResults", "1")457params.Set("fields", "id,key")458459req, err := i.jira.NewRequest("GET", "/rest/api/3/search/jql"+"?"+params.Encode(), nil)460if err != nil {461return jira.Issue{}, err462}463464var searchResult struct {465Issues []struct {466ID string `json:"id"`467Key string `json:"key"`468} `json:"issues"`469IsLast bool `json:"isLast"`470NextPageToken string `json:"nextPageToken"`471}472473resp, err := i.jira.Do(req, &searchResult)474if err != nil {475var data string476if resp != nil && resp.Body != nil {477d, _ := io.ReadAll(resp.Body)478data = string(d)479}480return jira.Issue{}, fmt.Errorf("%w => %s", err, data)481}482483if len(searchResult.Issues) == 0 {484return jira.Issue{}, nil485}486first := searchResult.Issues[0]487base := strings.TrimRight(i.options.URL, "/")488return jira.Issue{489ID: first.ID,490Key: first.Key,491Self: fmt.Sprintf("%s/rest/api/3/issue/%s", base, first.ID),492}, nil493}494495searchOptions := &jira.SearchOptionsV2{496MaxResults: 1, // if any issue exists, then we won't create a new one497Fields: []string{"summary", "description", "issuetype", "status", "priority", "project"},498}499500issues, resp, err := i.jira.Issue.SearchV2JQL(jql, searchOptions)501if err != nil {502var data string503if resp != nil && resp.Body != nil {504d, _ := io.ReadAll(resp.Body)505data = string(d)506}507return jira.Issue{}, fmt.Errorf("%w => %s", err, data)508}509510switch resp.Total {511case 0:512return jira.Issue{}, nil513case 1:514return issues[0], nil515default:516gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, issues[0].ID)517return issues[0], nil518}519}520521// ShouldFilter determines if an issue should be logged to this tracker522func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {523if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {524return false525}526527if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {528return false529}530531return true532}533534535