From fd024a3e8dba28540c7723655970a86a0d2b84dd Mon Sep 17 00:00:00 2001 From: Ice3man Date: Sun, 10 Mar 2024 22:02:42 +0530 Subject: [PATCH] feat: issue tracker URLs in JSON + misc fixes (#4855) * feat: issue tracker URLs in JSON + misc fixes * misc changes * feat: status update support for issues * feat: report metadata generation hook support * feat: added CLI summary of tickets created * misc changes --- cmd/integration-test/library.go | 2 +- internal/runner/options.go | 8 +- internal/runner/runner.go | 2 +- lib/sdk_private.go | 2 +- pkg/output/output.go | 10 ++ pkg/protocols/common/helpers/writer/writer.go | 11 +- pkg/reporting/client.go | 1 + pkg/reporting/format/format_utils.go | 13 +++ pkg/reporting/reporting.go | 101 ++++++++++++++++- pkg/reporting/trackers/filters/filters.go | 7 ++ pkg/reporting/trackers/gitea/gitea.go | 34 ++++-- pkg/reporting/trackers/github/github.go | 49 +++++++- pkg/reporting/trackers/gitlab/gitlab.go | 56 ++++++++- pkg/reporting/trackers/jira/jira.go | 106 +++++++++++++++--- pkg/tmplexec/exec.go | 1 + 15 files changed, 350 insertions(+), 53 deletions(-) diff --git a/cmd/integration-test/library.go b/cmd/integration-test/library.go index 62d88759b5..5fec42269e 100644 --- a/cmd/integration-test/library.go +++ b/cmd/integration-test/library.go @@ -69,7 +69,7 @@ func executeNucleiAsLibrary(templatePath, templateURL string) ([]string, error) defer cache.Close() mockProgress := &testutils.MockProgressClient{} - reportingClient, err := reporting.New(&reporting.Options{}, "") + reportingClient, err := reporting.New(&reporting.Options{}, "", false) if err != nil { return nil, err } diff --git a/internal/runner/options.go b/internal/runner/options.go index 76346c927a..21a2c2130e 100644 --- a/internal/runner/options.go +++ b/internal/runner/options.go @@ -290,12 +290,14 @@ func createReportingOptions(options *types.Options) (*reporting.Options, error) // configureOutput configures the output logging levels to be displayed on the screen func configureOutput(options *types.Options) { // If the user desires verbose output, show verbose output - if options.Verbose || options.Validate { - gologger.DefaultLogger.SetMaxLevel(levels.LevelVerbose) - } if options.Debug || options.DebugRequests || options.DebugResponse { gologger.DefaultLogger.SetMaxLevel(levels.LevelDebug) } + // Debug takes precedence before verbose + // because debug is a lower logging level. + if options.Verbose || options.Validate { + gologger.DefaultLogger.SetMaxLevel(levels.LevelVerbose) + } if options.NoColor { gologger.DefaultLogger.SetFormatter(formatter.NewCLI(true)) } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e6a3631e7b..114bfa9e6f 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -190,7 +190,7 @@ func New(options *types.Options) (*Runner, error) { } if reportingOptions != nil { - client, err := reporting.New(reportingOptions, options.ReportingDB) + client, err := reporting.New(reportingOptions, options.ReportingDB, false) if err != nil { return nil, errors.Wrap(err, "could not create issue reporting client") } diff --git a/lib/sdk_private.go b/lib/sdk_private.go index 3281ebdbef..f1d85b4397 100644 --- a/lib/sdk_private.go +++ b/lib/sdk_private.go @@ -128,7 +128,7 @@ func (e *NucleiEngine) init() error { return err } // we don't support reporting config in sdk mode - if e.rc, err = reporting.New(&reporting.Options{}, ""); err != nil { + if e.rc, err = reporting.New(&reporting.Options{}, "", false); err != nil { return err } e.interactshOpts.IssuesClient = e.rc diff --git a/pkg/output/output.go b/pkg/output/output.go index d2893cc38e..6d9064b925 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -165,10 +165,20 @@ type ResultEvent struct { // Lines is the line count for the specified match Lines []int `json:"matched-line,omitempty"` + // IssueTrackers is the metadata for issue trackers + IssueTrackers map[string]IssueTrackerMetadata `json:"issue_trackers,omitempty"` + FileToIndexPosition map[string]int `json:"-"` Error string `json:"error,omitempty"` } +type IssueTrackerMetadata struct { + // IssueID is the ID of the issue created + IssueID string `json:"id,omitempty"` + // IssueURL is the URL of the issue created + IssueURL string `json:"url,omitempty"` +} + // NewStandardWriter creates a new output writer based on user configurations func NewStandardWriter(options *types.Options) (*StandardWriter, error) { resumeBool := false diff --git a/pkg/protocols/common/helpers/writer/writer.go b/pkg/protocols/common/helpers/writer/writer.go index b68e584ff4..6666bde3c4 100644 --- a/pkg/protocols/common/helpers/writer/writer.go +++ b/pkg/protocols/common/helpers/writer/writer.go @@ -17,6 +17,11 @@ func WriteResult(data *output.InternalWrappedEvent, output output.Writer, progre } var matched bool for _, result := range data.Results { + if issuesClient != nil { + if err := issuesClient.CreateIssue(result); err != nil { + gologger.Warning().Msgf("Could not create issue on tracker: %s", err) + } + } if err := output.Write(result); err != nil { gologger.Warning().Msgf("Could not write output event: %s\n", err) } @@ -24,12 +29,6 @@ func WriteResult(data *output.InternalWrappedEvent, output output.Writer, progre matched = true } progress.IncrementMatched() - - if issuesClient != nil { - if err := issuesClient.CreateIssue(result); err != nil { - gologger.Warning().Msgf("Could not create issue on tracker: %s", err) - } - } } return matched } diff --git a/pkg/reporting/client.go b/pkg/reporting/client.go index 06b480d719..ce196f4ef3 100644 --- a/pkg/reporting/client.go +++ b/pkg/reporting/client.go @@ -11,5 +11,6 @@ type Client interface { Close() Clear() CreateIssue(event *output.ResultEvent) error + CloseIssue(event *output.ResultEvent) error GetReportingOptions() *Options } diff --git a/pkg/reporting/format/format_utils.go b/pkg/reporting/format/format_utils.go index 253742b37d..ccdb65d053 100644 --- a/pkg/reporting/format/format_utils.go +++ b/pkg/reporting/format/format_utils.go @@ -34,6 +34,13 @@ func GetMatchedTemplateName(event *output.ResultEvent) string { return matchedTemplateName } +type reportMetadataEditorHook func(event *output.ResultEvent, formatter ResultFormatter) string + +var ( + // ReportGenerationMetadataHooks are the hooks for adding metadata to the report + ReportGenerationMetadataHooks []reportMetadataEditorHook +) + func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatter, omitRaw bool) string { template := GetMatchedTemplateName(event) builder := &bytes.Buffer{} @@ -137,6 +144,12 @@ func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatte builder.WriteString("\n" + formatter.CreateHorizontalLine() + "\n") builder.WriteString(fmt.Sprintf("Generated by %s", formatter.CreateLink("Nuclei "+config.Version, "https://github.com/projectdiscovery/nuclei"))) + + if len(ReportGenerationMetadataHooks) > 0 { + for _, hook := range ReportGenerationMetadataHooks { + builder.WriteString(hook(event, formatter)) + } + } data := builder.String() return data } diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go index 8d1e0bbe9d..c9fe0f4bab 100644 --- a/pkg/reporting/reporting.go +++ b/pkg/reporting/reporting.go @@ -1,8 +1,12 @@ package reporting import ( + "fmt" "os" + "strings" + "sync/atomic" + "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl" @@ -35,8 +39,12 @@ var ( // Tracker is an interface implemented by an issue tracker type Tracker interface { + // Name returns the name of the tracker + Name() string // CreateIssue creates an issue in the tracker - CreateIssue(event *output.ResultEvent) error + CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) + // CloseIssue closes an issue in the tracker + CloseIssue(event *output.ResultEvent) error // ShouldFilter determines if the event should be filtered out ShouldFilter(event *output.ResultEvent) bool } @@ -55,10 +63,17 @@ type ReportingClient struct { exporters []Exporter options *Options dedupe *dedupe.Storage + + stats map[string]*IssueTrackerStats +} + +type IssueTrackerStats struct { + Created atomic.Int32 + Failed atomic.Int32 } // New creates a new nuclei issue tracker reporting client -func New(options *Options, db string) (Client, error) { +func New(options *Options, db string, doNotDedupe bool) (Client, error) { client := &ReportingClient{options: options} if options.GitHub != nil { @@ -142,6 +157,20 @@ func New(options *Options, db string) (Client, error) { client.exporters = append(client.exporters, exporter) } + if doNotDedupe { + return client, nil + } + + client.stats = make(map[string]*IssueTrackerStats) + for _, tracker := range client.trackers { + trackerName := tracker.Name() + + client.stats[trackerName] = &IssueTrackerStats{ + Created: atomic.Int32{}, + Failed: atomic.Int32{}, + } + } + storage, err := dedupe.New(db) if err != nil { return nil, err @@ -195,7 +224,30 @@ func (c *ReportingClient) RegisterExporter(exporter Exporter) { // Close closes the issue tracker reporting client func (c *ReportingClient) Close() { - c.dedupe.Close() + // If we have stats for the trackers, print them + if len(c.stats) > 0 { + for _, tracker := range c.trackers { + trackerName := tracker.Name() + + if stats, ok := c.stats[trackerName]; ok { + created := stats.Created.Load() + if created == 0 { + continue + } + var msgBuilder strings.Builder + msgBuilder.WriteString(fmt.Sprintf("%d %s tickets created successfully", created, trackerName)) + failed := stats.Failed.Load() + if failed > 0 { + msgBuilder.WriteString(fmt.Sprintf(", %d failed", failed)) + } + gologger.Info().Msgf(msgBuilder.String()) + } + } + } + + if c.dedupe != nil { + c.dedupe.Close() + } for _, exporter := range c.exporters { exporter.Close() } @@ -211,15 +263,37 @@ func (c *ReportingClient) CreateIssue(event *output.ResultEvent) error { return nil } - unique, err := c.dedupe.Index(event) + var err error + unique := true + if c.dedupe != nil { + unique, err = c.dedupe.Index(event) + } if unique { + event.IssueTrackers = make(map[string]output.IssueTrackerMetadata) + for _, tracker := range c.trackers { // process tracker specific allow/deny list if tracker.ShouldFilter(event) { continue } - if trackerErr := tracker.CreateIssue(event); trackerErr != nil { + trackerName := tracker.Name() + stats, statsOk := c.stats[trackerName] + + reportData, trackerErr := tracker.CreateIssue(event) + if trackerErr != nil { + if statsOk { + _ = stats.Failed.Add(1) + } err = multierr.Append(err, trackerErr) + continue + } + if statsOk { + _ = stats.Created.Add(1) + } + + event.IssueTrackers[tracker.Name()] = output.IssueTrackerMetadata{ + IssueID: reportData.IssueID, + IssueURL: reportData.IssueURL, } } for _, exporter := range c.exporters { @@ -231,10 +305,25 @@ func (c *ReportingClient) CreateIssue(event *output.ResultEvent) error { return err } +// CloseIssue closes an issue in the tracker +func (c *ReportingClient) CloseIssue(event *output.ResultEvent) error { + for _, tracker := range c.trackers { + if tracker.ShouldFilter(event) { + continue + } + if err := tracker.CloseIssue(event); err != nil { + return err + } + } + return nil +} + func (c *ReportingClient) GetReportingOptions() *Options { return c.options } func (c *ReportingClient) Clear() { - c.dedupe.Clear() + if c.dedupe != nil { + c.dedupe.Clear() + } } diff --git a/pkg/reporting/trackers/filters/filters.go b/pkg/reporting/trackers/filters/filters.go index bc8d7f1c93..390bcc7abd 100644 --- a/pkg/reporting/trackers/filters/filters.go +++ b/pkg/reporting/trackers/filters/filters.go @@ -8,6 +8,13 @@ import ( sliceutil "github.com/projectdiscovery/utils/slice" ) +// CreateIssueResponse is a response to creating an issue +// in a tracker +type CreateIssueResponse struct { + IssueID string `json:"issue_id"` + IssueURL string `json:"issue_url"` +} + // Filter filters the received event and decides whether to perform // reporting for it or not. type Filter struct { diff --git a/pkg/reporting/trackers/gitea/gitea.go b/pkg/reporting/trackers/gitea/gitea.go index d5fb956d9d..70fbe7fe51 100644 --- a/pkg/reporting/trackers/gitea/gitea.go +++ b/pkg/reporting/trackers/gitea/gitea.go @@ -3,6 +3,7 @@ package gitea import ( "fmt" "net/url" + "strconv" "strings" "code.gitea.io/sdk/gitea" @@ -79,7 +80,7 @@ func New(options *Options) (*Integration, error) { } // CreateIssue creates an issue in the tracker -func (i *Integration) CreateIssue(event *output.ResultEvent) error { +func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) { summary := format.Summary(event) description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) @@ -93,32 +94,47 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error { } customLabels, err := i.getLabelIDsByNames(labels) if err != nil { - return err + return nil, err } var issue *gitea.Issue if i.options.DuplicateIssueCheck { issue, err = i.findIssueByTitle(summary) if err != nil { - return err + return nil, err } } if issue == nil { - _, _, err = i.client.CreateIssue(i.options.ProjectOwner, i.options.ProjectName, gitea.CreateIssueOption{ + createdIssue, _, err := i.client.CreateIssue(i.options.ProjectOwner, i.options.ProjectName, gitea.CreateIssueOption{ Title: summary, Body: description, Labels: customLabels, }) - - return err + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(createdIssue.Index, 10), + IssueURL: createdIssue.URL, + }, nil } _, _, err = i.client.CreateIssueComment(i.options.ProjectOwner, i.options.ProjectName, issue.Index, gitea.CreateIssueCommentOption{ Body: description, }) + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(issue.Index, 10), + IssueURL: issue.URL, + }, nil +} - return err +func (i *Integration) CloseIssue(event *output.ResultEvent) error { + // TODO: Implement + return nil } // ShouldFilter determines if an issue should be logged to this tracker @@ -192,3 +208,7 @@ func (i *Integration) getLabelIDsByNames(labels []string) ([]int64, error) { return ids, nil } + +func (i *Integration) Name() string { + return "gitea" +} diff --git a/pkg/reporting/trackers/github/github.go b/pkg/reporting/trackers/github/github.go index 3000166df5..102692e6ce 100644 --- a/pkg/reporting/trackers/github/github.go +++ b/pkg/reporting/trackers/github/github.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/google/go-github/github" @@ -85,7 +86,7 @@ func New(options *Options) (*Integration, error) { } // CreateIssue creates an issue in the tracker -func (i *Integration) CreateIssue(event *output.ResultEvent) (err error) { +func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) { summary := format.Summary(event) description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) labels := []string{} @@ -99,11 +100,12 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) (err error) { ctx := context.Background() + var err error var existingIssue *github.Issue if i.options.DuplicateIssueCheck { existingIssue, err = i.findIssueByTitle(ctx, summary) if err != nil && !errors.Is(err, io.EOF) { - return err + return nil, err } } @@ -114,15 +116,21 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) (err error) { Labels: &labels, Assignees: &[]string{i.options.Username}, } - _, _, err = i.client.Issues.Create(ctx, i.options.Owner, i.options.ProjectName, req) - return err + createdIssue, _, err := i.client.Issues.Create(ctx, i.options.Owner, i.options.ProjectName, req) + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(createdIssue.GetID(), 10), + IssueURL: createdIssue.GetHTMLURL(), + }, nil } else { if existingIssue.GetState() == "closed" { stateOpen := "open" if _, _, err := i.client.Issues.Edit(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, &github.IssueRequest{ State: &stateOpen, }); err != nil { - return fmt.Errorf("error reopening issue %d: %s", *existingIssue.Number, err) + return nil, fmt.Errorf("error reopening issue %d: %s", *existingIssue.Number, err) } } @@ -130,8 +138,39 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) (err error) { Body: &description, } _, _, err = i.client.Issues.CreateComment(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, req) + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(existingIssue.GetID(), 10), + IssueURL: existingIssue.GetHTMLURL(), + }, nil + } +} + +func (i *Integration) CloseIssue(event *output.ResultEvent) error { + ctx := context.Background() + summary := format.Summary(event) + + existingIssue, err := i.findIssueByTitle(ctx, summary) + if err != nil && !errors.Is(err, io.EOF) { return err } + if existingIssue == nil { + return nil + } + + stateClosed := "closed" + if _, _, err := i.client.Issues.Edit(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, &github.IssueRequest{ + State: &stateClosed, + }); err != nil { + return fmt.Errorf("error closing issue %d: %s", *existingIssue.Number, err) + } + return nil +} + +func (i *Integration) Name() string { + return "github" } // ShouldFilter determines if an issue should be logged to this tracker diff --git a/pkg/reporting/trackers/gitlab/gitlab.go b/pkg/reporting/trackers/gitlab/gitlab.go index 81d1109fe0..dc5bef0d6e 100644 --- a/pkg/reporting/trackers/gitlab/gitlab.go +++ b/pkg/reporting/trackers/gitlab/gitlab.go @@ -2,6 +2,7 @@ package gitlab import ( "fmt" + "strconv" "github.com/xanzy/go-gitlab" @@ -66,7 +67,7 @@ func New(options *Options) (*Integration, error) { } // CreateIssue creates an issue in the tracker -func (i *Integration) CreateIssue(event *output.ResultEvent) error { +func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) { summary := format.Summary(event) description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) labels := []string{} @@ -88,7 +89,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error { Search: &summary, }) if err != nil { - return err + return nil, err } if len(issues) > 0 { issue := issues[0] @@ -96,7 +97,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error { Body: &description, }) if err != nil { - return err + return nil, err } if issue.State == "closed" { reopen := "reopen" @@ -105,17 +106,60 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error { }) fmt.Sprintln(resp, err) } - return err + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(int64(issue.ID), 10), + IssueURL: issue.WebURL, + }, nil } } - _, _, err := i.client.Issues.CreateIssue(i.options.ProjectName, &gitlab.CreateIssueOptions{ + createdIssue, _, err := i.client.Issues.CreateIssue(i.options.ProjectName, &gitlab.CreateIssueOptions{ Title: &summary, Description: &description, Labels: &customLabels, AssigneeIDs: &assigneeIDs, }) + if err != nil { + return nil, err + } + return &filters.CreateIssueResponse{ + IssueID: strconv.FormatInt(int64(createdIssue.ID), 10), + IssueURL: createdIssue.WebURL, + }, nil +} + +func (i *Integration) Name() string { + return "gitlab" +} + +func (i *Integration) CloseIssue(event *output.ResultEvent) error { + searchIn := "title" + searchState := "all" + + summary := format.Summary(event) + issues, _, err := i.client.Issues.ListProjectIssues(i.options.ProjectName, &gitlab.ListProjectIssuesOptions{ + In: &searchIn, + State: &searchState, + Search: &summary, + }) + if err != nil { + return err + } + if len(issues) <= 0 { + return nil + } - return err + issue := issues[0] + state := "close" + _, _, err = i.client.Issues.UpdateIssue(i.options.ProjectName, issue.IID, &gitlab.UpdateIssueOptions{ + StateEvent: &state, + }) + if err != nil { + return err + } + return nil } // ShouldFilter determines if an issue should be logged to this tracker diff --git a/pkg/reporting/trackers/jira/jira.go b/pkg/reporting/trackers/jira/jira.go index e0f0a0d6fd..97ae078312 100644 --- a/pkg/reporting/trackers/jira/jira.go +++ b/pkg/reporting/trackers/jira/jira.go @@ -3,7 +3,9 @@ package jira import ( "fmt" "io" + "net/url" "strings" + "sync" "github.com/andygrunwald/go-jira" "github.com/trivago/tgo/tcontainer" @@ -47,6 +49,9 @@ type Integration struct { Formatter jira *jira.Client options *Options + + once *sync.Once + transitionID string } // Options contains the configuration options for jira client @@ -102,11 +107,20 @@ func New(options *Options) (*Integration, error) { if err != nil { return nil, err } - return &Integration{jira: jiraClient, options: options}, nil + integration := &Integration{ + jira: jiraClient, + options: options, + once: &sync.Once{}, + } + return integration, nil +} + +func (i *Integration) Name() string { + return "jira" } // CreateNewIssue creates a new issue in the tracker -func (i *Integration) CreateNewIssue(event *output.ResultEvent) error { +func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) { summary := format.Summary(event) labels := []string{} severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String()) @@ -127,7 +141,7 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error { for nestedName, nestedValue := range valueMap { fmtNestedValue, ok := nestedValue.(string) if !ok { - return fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue) + return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue) } if strings.HasPrefix(fmtNestedValue, "$") { nestedValue = strings.TrimPrefix(fmtNestedValue, "$") @@ -160,8 +174,10 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error { } } fields := &jira.IssueFields{ + Assignee: &jira.User{Name: i.options.AccountID}, Description: format.CreateReportDescription(event, i, i.options.OmitRaw), Unknowns: customFields, + Labels: labels, Type: jira.IssueType{Name: i.options.IssueType}, Project: jira.Project{Key: i.options.ProjectName}, Summary: summary, @@ -182,36 +198,92 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error { issueData := &jira.Issue{ Fields: fields, } - _, resp, err := i.jira.Issue.Create(issueData) + createdIssue, resp, err := i.jira.Issue.Create(issueData) if err != nil { var data string if resp != nil && resp.Body != nil { d, _ := io.ReadAll(resp.Body) data = string(d) } - return fmt.Errorf("%w => %s", err, data) + return nil, fmt.Errorf("%w => %s", err, data) } - return nil + return getIssueResponseFromJira(createdIssue) +} + +func getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) { + parsed, err := url.Parse(issue.Self) + if err != nil { + return nil, err + } + parsed.Path = fmt.Sprintf("/browse/%s", issue.Key) + issueURL := parsed.String() + + return &filters.CreateIssueResponse{ + IssueID: issue.ID, + IssueURL: issueURL, + }, nil } // CreateIssue creates an issue in the tracker or updates the existing one -func (i *Integration) CreateIssue(event *output.ResultEvent) error { +func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) { if i.options.UpdateExisting { - issueID, err := i.FindExistingIssue(event) + issue, err := i.FindExistingIssue(event) if err != nil { - return err - } else if issueID != "" { - _, _, err = i.jira.Issue.AddComment(issueID, &jira.Comment{ + return nil, err + } else if issue.ID != "" { + _, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{ Body: format.CreateReportDescription(event, i, i.options.OmitRaw), }) - return err + if err != nil { + return nil, err + } + return getIssueResponseFromJira(&issue) } } return i.CreateNewIssue(event) } +func (i *Integration) CloseIssue(event *output.ResultEvent) error { + if i.options.StatusNot == "" { + return nil + } + + issue, err := i.FindExistingIssue(event) + if err != nil { + return err + } else if issue.ID != "" { + // Lazy load the transitions ID in case it's not set + i.once.Do(func() { + transitions, _, err := i.jira.Issue.GetTransitions(issue.ID) + if err != nil { + return + } + for _, transition := range transitions { + if transition.Name == i.options.StatusNot { + i.transitionID = transition.ID + break + } + } + }) + if i.transitionID == "" { + return nil + } + transition := jira.CreateTransitionPayload{ + Transition: jira.TransitionPayload{ + ID: i.transitionID, + }, + } + + _, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition) + if err != nil { + return err + } + } + return nil +} + // FindExistingIssue checks if the issue already exists and returns its ID -func (i *Integration) FindExistingIssue(event *output.ResultEvent) (string, error) { +func (i *Integration) FindExistingIssue(event *output.ResultEvent) (jira.Issue, error) { template := format.GetMatchedTemplateName(event) jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND status != \"%s\" AND project = \"%s\"", template, event.Host, i.options.StatusNot, i.options.ProjectName) @@ -226,17 +298,17 @@ func (i *Integration) FindExistingIssue(event *output.ResultEvent) (string, erro d, _ := io.ReadAll(resp.Body) data = string(d) } - return "", fmt.Errorf("%w => %s", err, data) + return jira.Issue{}, fmt.Errorf("%w => %s", err, data) } switch resp.Total { case 0: - return "", nil + return jira.Issue{}, nil case 1: - return chunk[0].ID, nil + return chunk[0], nil default: gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, chunk[0].ID) - return chunk[0].ID, nil + return chunk[0], nil } } diff --git a/pkg/tmplexec/exec.go b/pkg/tmplexec/exec.go index 3faaa836c7..5035a1ff09 100644 --- a/pkg/tmplexec/exec.go +++ b/pkg/tmplexec/exec.go @@ -114,6 +114,7 @@ func (e *TemplateExecuter) Execute(ctx *scan.ScanContext) (bool, error) { // try catching unknown panics if r := recover(); r != nil { ctx.LogError(fmt.Errorf("panic: %v", r)) + gologger.Verbose().Msgf("panic: %v", r) } }()