Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: issue tracker URLs in JSON + misc fixes #4855

Merged
merged 6 commits into from
Mar 10, 2024
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
2 changes: 1 addition & 1 deletion cmd/integration-test/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 5 additions & 3 deletions internal/runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
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
Loading