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
This commit is contained in:
Ice3man 2024-03-10 22:02:42 +05:30 committed by GitHub
parent b1b4f0fe76
commit fd024a3e8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 350 additions and 53 deletions

View File

@ -69,7 +69,7 @@ func executeNucleiAsLibrary(templatePath, templateURL string) ([]string, error)
defer cache.Close() defer cache.Close()
mockProgress := &testutils.MockProgressClient{} mockProgress := &testutils.MockProgressClient{}
reportingClient, err := reporting.New(&reporting.Options{}, "") reportingClient, err := reporting.New(&reporting.Options{}, "", false)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -290,12 +290,14 @@ func createReportingOptions(options *types.Options) (*reporting.Options, error)
// configureOutput configures the output logging levels to be displayed on the screen // configureOutput configures the output logging levels to be displayed on the screen
func configureOutput(options *types.Options) { func configureOutput(options *types.Options) {
// If the user desires verbose output, show verbose output // 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 { if options.Debug || options.DebugRequests || options.DebugResponse {
gologger.DefaultLogger.SetMaxLevel(levels.LevelDebug) 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 { if options.NoColor {
gologger.DefaultLogger.SetFormatter(formatter.NewCLI(true)) gologger.DefaultLogger.SetFormatter(formatter.NewCLI(true))
} }

View File

@ -190,7 +190,7 @@ func New(options *types.Options) (*Runner, error) {
} }
if reportingOptions != nil { if reportingOptions != nil {
client, err := reporting.New(reportingOptions, options.ReportingDB) client, err := reporting.New(reportingOptions, options.ReportingDB, false)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not create issue reporting client") return nil, errors.Wrap(err, "could not create issue reporting client")
} }

View File

@ -128,7 +128,7 @@ func (e *NucleiEngine) init() error {
return err return err
} }
// we don't support reporting config in sdk mode // 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 return err
} }
e.interactshOpts.IssuesClient = e.rc e.interactshOpts.IssuesClient = e.rc

View File

@ -165,10 +165,20 @@ type ResultEvent struct {
// Lines is the line count for the specified match // Lines is the line count for the specified match
Lines []int `json:"matched-line,omitempty"` 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:"-"` FileToIndexPosition map[string]int `json:"-"`
Error string `json:"error,omitempty"` 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 // NewStandardWriter creates a new output writer based on user configurations
func NewStandardWriter(options *types.Options) (*StandardWriter, error) { func NewStandardWriter(options *types.Options) (*StandardWriter, error) {
resumeBool := false resumeBool := false

View File

@ -17,6 +17,11 @@ func WriteResult(data *output.InternalWrappedEvent, output output.Writer, progre
} }
var matched bool var matched bool
for _, result := range data.Results { 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 { if err := output.Write(result); err != nil {
gologger.Warning().Msgf("Could not write output event: %s\n", err) 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 matched = true
} }
progress.IncrementMatched() 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 return matched
} }

View File

@ -11,5 +11,6 @@ type Client interface {
Close() Close()
Clear() Clear()
CreateIssue(event *output.ResultEvent) error CreateIssue(event *output.ResultEvent) error
CloseIssue(event *output.ResultEvent) error
GetReportingOptions() *Options GetReportingOptions() *Options
} }

View File

@ -34,6 +34,13 @@ func GetMatchedTemplateName(event *output.ResultEvent) string {
return matchedTemplateName 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 { func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatter, omitRaw bool) string {
template := GetMatchedTemplateName(event) template := GetMatchedTemplateName(event)
builder := &bytes.Buffer{} builder := &bytes.Buffer{}
@ -137,6 +144,12 @@ func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatte
builder.WriteString("\n" + formatter.CreateHorizontalLine() + "\n") builder.WriteString("\n" + formatter.CreateHorizontalLine() + "\n")
builder.WriteString(fmt.Sprintf("Generated by %s", formatter.CreateLink("Nuclei "+config.Version, "https://github.com/projectdiscovery/nuclei"))) 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() data := builder.String()
return data return data
} }

View File

@ -1,8 +1,12 @@
package reporting package reporting
import ( import (
"fmt"
"os" "os"
"strings"
"sync/atomic"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter" json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
@ -35,8 +39,12 @@ var (
// Tracker is an interface implemented by an issue tracker // Tracker is an interface implemented by an issue tracker
type Tracker interface { type Tracker interface {
// Name returns the name of the tracker
Name() string
// CreateIssue creates an issue in the tracker // 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 determines if the event should be filtered out
ShouldFilter(event *output.ResultEvent) bool ShouldFilter(event *output.ResultEvent) bool
} }
@ -55,10 +63,17 @@ type ReportingClient struct {
exporters []Exporter exporters []Exporter
options *Options options *Options
dedupe *dedupe.Storage 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 // 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} client := &ReportingClient{options: options}
if options.GitHub != nil { if options.GitHub != nil {
@ -142,6 +157,20 @@ func New(options *Options, db string) (Client, error) {
client.exporters = append(client.exporters, exporter) 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) storage, err := dedupe.New(db)
if err != nil { if err != nil {
return nil, err return nil, err
@ -195,7 +224,30 @@ func (c *ReportingClient) RegisterExporter(exporter Exporter) {
// Close closes the issue tracker reporting client // Close closes the issue tracker reporting client
func (c *ReportingClient) Close() { 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 { for _, exporter := range c.exporters {
exporter.Close() exporter.Close()
} }
@ -211,15 +263,37 @@ func (c *ReportingClient) CreateIssue(event *output.ResultEvent) error {
return nil 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 { if unique {
event.IssueTrackers = make(map[string]output.IssueTrackerMetadata)
for _, tracker := range c.trackers { for _, tracker := range c.trackers {
// process tracker specific allow/deny list // process tracker specific allow/deny list
if tracker.ShouldFilter(event) { if tracker.ShouldFilter(event) {
continue 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) 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 { for _, exporter := range c.exporters {
@ -231,10 +305,25 @@ func (c *ReportingClient) CreateIssue(event *output.ResultEvent) error {
return err 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 { func (c *ReportingClient) GetReportingOptions() *Options {
return c.options return c.options
} }
func (c *ReportingClient) Clear() { func (c *ReportingClient) Clear() {
c.dedupe.Clear() if c.dedupe != nil {
c.dedupe.Clear()
}
} }

View File

@ -8,6 +8,13 @@ import (
sliceutil "github.com/projectdiscovery/utils/slice" 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 // Filter filters the received event and decides whether to perform
// reporting for it or not. // reporting for it or not.
type Filter struct { type Filter struct {

View File

@ -3,6 +3,7 @@ package gitea
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"strconv"
"strings" "strings"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
@ -79,7 +80,7 @@ func New(options *Options) (*Integration, error) {
} }
// CreateIssue creates an issue in the tracker // 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) summary := format.Summary(event)
description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) 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) customLabels, err := i.getLabelIDsByNames(labels)
if err != nil { if err != nil {
return err return nil, err
} }
var issue *gitea.Issue var issue *gitea.Issue
if i.options.DuplicateIssueCheck { if i.options.DuplicateIssueCheck {
issue, err = i.findIssueByTitle(summary) issue, err = i.findIssueByTitle(summary)
if err != nil { if err != nil {
return err return nil, err
} }
} }
if issue == nil { 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, Title: summary,
Body: description, Body: description,
Labels: customLabels, Labels: customLabels,
}) })
if err != nil {
return err 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{ _, _, err = i.client.CreateIssueComment(i.options.ProjectOwner, i.options.ProjectName, issue.Index, gitea.CreateIssueCommentOption{
Body: description, 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 // 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 return ids, nil
} }
func (i *Integration) Name() string {
return "gitea"
}

View File

@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"github.com/google/go-github/github" "github.com/google/go-github/github"
@ -85,7 +86,7 @@ func New(options *Options) (*Integration, error) {
} }
// CreateIssue creates an issue in the tracker // 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) summary := format.Summary(event)
description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw)
labels := []string{} labels := []string{}
@ -99,11 +100,12 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) (err error) {
ctx := context.Background() ctx := context.Background()
var err error
var existingIssue *github.Issue var existingIssue *github.Issue
if i.options.DuplicateIssueCheck { if i.options.DuplicateIssueCheck {
existingIssue, err = i.findIssueByTitle(ctx, summary) existingIssue, err = i.findIssueByTitle(ctx, summary)
if err != nil && !errors.Is(err, io.EOF) { 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, Labels: &labels,
Assignees: &[]string{i.options.Username}, Assignees: &[]string{i.options.Username},
} }
_, _, err = i.client.Issues.Create(ctx, i.options.Owner, i.options.ProjectName, req) createdIssue, _, err := i.client.Issues.Create(ctx, i.options.Owner, i.options.ProjectName, req)
return err if err != nil {
return nil, err
}
return &filters.CreateIssueResponse{
IssueID: strconv.FormatInt(createdIssue.GetID(), 10),
IssueURL: createdIssue.GetHTMLURL(),
}, nil
} else { } else {
if existingIssue.GetState() == "closed" { if existingIssue.GetState() == "closed" {
stateOpen := "open" stateOpen := "open"
if _, _, err := i.client.Issues.Edit(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, &github.IssueRequest{ if _, _, err := i.client.Issues.Edit(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, &github.IssueRequest{
State: &stateOpen, State: &stateOpen,
}); err != nil { }); 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, Body: &description,
} }
_, _, err = i.client.Issues.CreateComment(ctx, i.options.Owner, i.options.ProjectName, *existingIssue.Number, req) _, _, 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 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 // ShouldFilter determines if an issue should be logged to this tracker

View File

@ -2,6 +2,7 @@ package gitlab
import ( import (
"fmt" "fmt"
"strconv"
"github.com/xanzy/go-gitlab" "github.com/xanzy/go-gitlab"
@ -66,7 +67,7 @@ func New(options *Options) (*Integration, error) {
} }
// CreateIssue creates an issue in the tracker // 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) summary := format.Summary(event)
description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw) description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw)
labels := []string{} labels := []string{}
@ -88,7 +89,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error {
Search: &summary, Search: &summary,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if len(issues) > 0 { if len(issues) > 0 {
issue := issues[0] issue := issues[0]
@ -96,7 +97,7 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error {
Body: &description, Body: &description,
}) })
if err != nil { if err != nil {
return err return nil, err
} }
if issue.State == "closed" { if issue.State == "closed" {
reopen := "reopen" reopen := "reopen"
@ -105,17 +106,60 @@ func (i *Integration) CreateIssue(event *output.ResultEvent) error {
}) })
fmt.Sprintln(resp, err) 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, Title: &summary,
Description: &description, Description: &description,
Labels: &customLabels, Labels: &customLabels,
AssigneeIDs: &assigneeIDs, AssigneeIDs: &assigneeIDs,
}) })
if err != nil {
return nil, err
}
return &filters.CreateIssueResponse{
IssueID: strconv.FormatInt(int64(createdIssue.ID), 10),
IssueURL: createdIssue.WebURL,
}, nil
}
return err 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
}
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 // ShouldFilter determines if an issue should be logged to this tracker

View File

@ -3,7 +3,9 @@ package jira
import ( import (
"fmt" "fmt"
"io" "io"
"net/url"
"strings" "strings"
"sync"
"github.com/andygrunwald/go-jira" "github.com/andygrunwald/go-jira"
"github.com/trivago/tgo/tcontainer" "github.com/trivago/tgo/tcontainer"
@ -47,6 +49,9 @@ type Integration struct {
Formatter Formatter
jira *jira.Client jira *jira.Client
options *Options options *Options
once *sync.Once
transitionID string
} }
// Options contains the configuration options for jira client // Options contains the configuration options for jira client
@ -102,11 +107,20 @@ func New(options *Options) (*Integration, error) {
if err != nil { if err != nil {
return nil, err 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 // 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) summary := format.Summary(event)
labels := []string{} labels := []string{}
severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.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 { for nestedName, nestedValue := range valueMap {
fmtNestedValue, ok := nestedValue.(string) fmtNestedValue, ok := nestedValue.(string)
if !ok { 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, "$") { if strings.HasPrefix(fmtNestedValue, "$") {
nestedValue = strings.TrimPrefix(fmtNestedValue, "$") nestedValue = strings.TrimPrefix(fmtNestedValue, "$")
@ -160,8 +174,10 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error {
} }
} }
fields := &jira.IssueFields{ fields := &jira.IssueFields{
Assignee: &jira.User{Name: i.options.AccountID},
Description: format.CreateReportDescription(event, i, i.options.OmitRaw), Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
Unknowns: customFields, Unknowns: customFields,
Labels: labels,
Type: jira.IssueType{Name: i.options.IssueType}, Type: jira.IssueType{Name: i.options.IssueType},
Project: jira.Project{Key: i.options.ProjectName}, Project: jira.Project{Key: i.options.ProjectName},
Summary: summary, Summary: summary,
@ -182,36 +198,92 @@ func (i *Integration) CreateNewIssue(event *output.ResultEvent) error {
issueData := &jira.Issue{ issueData := &jira.Issue{
Fields: fields, Fields: fields,
} }
_, resp, err := i.jira.Issue.Create(issueData) createdIssue, resp, err := i.jira.Issue.Create(issueData)
if err != nil { if err != nil {
var data string var data string
if resp != nil && resp.Body != nil { if resp != nil && resp.Body != nil {
d, _ := io.ReadAll(resp.Body) d, _ := io.ReadAll(resp.Body)
data = string(d) 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 // 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 { if i.options.UpdateExisting {
issueID, err := i.FindExistingIssue(event) issue, err := i.FindExistingIssue(event)
if err != nil { if err != nil {
return err return nil, err
} else if issueID != "" { } else if issue.ID != "" {
_, _, err = i.jira.Issue.AddComment(issueID, &jira.Comment{ _, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{
Body: format.CreateReportDescription(event, i, i.options.OmitRaw), Body: format.CreateReportDescription(event, i, i.options.OmitRaw),
}) })
return err if err != nil {
return nil, err
}
return getIssueResponseFromJira(&issue)
} }
} }
return i.CreateNewIssue(event) 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 // 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) 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) 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) d, _ := io.ReadAll(resp.Body)
data = string(d) data = string(d)
} }
return "", fmt.Errorf("%w => %s", err, data) return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
} }
switch resp.Total { switch resp.Total {
case 0: case 0:
return "", nil return jira.Issue{}, nil
case 1: case 1:
return chunk[0].ID, nil return chunk[0], nil
default: 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) 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
} }
} }

View File

@ -114,6 +114,7 @@ func (e *TemplateExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
// try catching unknown panics // try catching unknown panics
if r := recover(); r != nil { if r := recover(); r != nil {
ctx.LogError(fmt.Errorf("panic: %v", r)) ctx.LogError(fmt.Errorf("panic: %v", r))
gologger.Verbose().Msgf("panic: %v", r)
} }
}() }()