Skip to content

Commit

Permalink
feat: issue tracker URLs in JSON + misc fixes (#4855)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Ice3man543 authored and tarunKoyalwar committed Mar 10, 2024
1 parent 1d56767 commit 97e3907
Show file tree
Hide file tree
Showing 15 changed files with 352 additions and 55 deletions.
2 changes: 1 addition & 1 deletion cmd/integration-test/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,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
}
Expand Down
12 changes: 7 additions & 5 deletions internal/runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,17 @@ func configureOutput(options *types.Options) {
gologger.DefaultLogger.SetFormatter(formatter.NewCLI(true))
}
// If the user desires verbose output, show verbose output
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)
return
}
if options.Debug || options.DebugRequests || options.DebugResponse {
gologger.DefaultLogger.SetMaxLevel(levels.LevelDebug)
return
if options.NoColor {
gologger.DefaultLogger.SetFormatter(formatter.NewCLI(true))
}

if options.Silent {
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion lib/sdk_private.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions pkg/protocols/common/helpers/writer/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,18 @@ 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)
}
if !matched {
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
}
1 change: 1 addition & 0 deletions pkg/reporting/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ type Client interface {
Close()
Clear()
CreateIssue(event *output.ResultEvent) error
CloseIssue(event *output.ResultEvent) error
GetReportingOptions() *Options
}
13 changes: 13 additions & 0 deletions pkg/reporting/format/format_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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
}
Expand Down
101 changes: 95 additions & 6 deletions pkg/reporting/reporting.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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 {
Expand All @@ -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()
}
}
7 changes: 7 additions & 0 deletions pkg/reporting/trackers/filters/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 27 additions & 7 deletions pkg/reporting/trackers/gitea/gitea.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitea
import (
"fmt"
"net/url"
"strconv"
"strings"

"code.gitea.io/sdk/gitea"
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -192,3 +208,7 @@ func (i *Integration) getLabelIDsByNames(labels []string) ([]int64, error) {

return ids, nil
}

func (i *Integration) Name() string {
return "gitea"
}
Loading

0 comments on commit 97e3907

Please sign in to comment.