package github import ( "context" "fmt" "io" "net/http" "net/url" "strconv" "strings" "github.com/google/go-github/github" "github.com/pkg/errors" "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/format" "github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters" "github.com/projectdiscovery/nuclei/v3/pkg/types" "github.com/projectdiscovery/retryablehttp-go" "golang.org/x/oauth2" ) // Integration is a client for an issue tracker integration type Integration struct { client *github.Client options *Options } // Options contains the configuration options for GitHub issue tracker client type Options struct { // BaseURL (optional) is the self-hosted GitHub application url BaseURL string `yaml:"base-url" validate:"omitempty,url"` // Username is the username of the GitHub user Username string `yaml:"username" validate:"required"` // Owner is the owner name of the repository for issues. Owner string `yaml:"owner" validate:"required"` // Token is the token for GitHub account. Token string `yaml:"token" validate:"required"` // ProjectName is the name of the repository. ProjectName string `yaml:"project-name" validate:"required"` // IssueLabel (optional) is the label of the created issue type IssueLabel string `yaml:"issue-label"` // SeverityAsLabel (optional) sends the severity as the label of the created // issue. SeverityAsLabel bool `yaml:"severity-as-label"` // AllowList contains a list of allowed events for this tracker AllowList *filters.Filter `yaml:"allow-list"` // DenyList contains a list of denied events for this tracker DenyList *filters.Filter `yaml:"deny-list"` // DuplicateIssueCheck (optional) comments under existing finding issue // instead of creating duplicates for subsequent runs. DuplicateIssueCheck bool `yaml:"duplicate-issue-check"` HttpClient *retryablehttp.Client `yaml:"-"` OmitRaw bool `yaml:"-"` } // New creates a new issue tracker integration client based on options. func New(options *Options) (*Integration, error) { ctx := context.Background() ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: options.Token}, ) tc := oauth2.NewClient(ctx, ts) // patch transport to support proxy - only http // TODO: investigate if it's possible to reuse existing retryablehttp if types.ProxyURL != "" { if proxyURL, err := url.Parse(types.ProxyURL); err == nil { tc.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL) } } client := github.NewClient(tc) if options.BaseURL != "" { parsed, err := url.Parse(options.BaseURL) if err != nil { return nil, errors.Wrap(err, "could not parse custom baseurl") } if !strings.HasSuffix(parsed.Path, "/") { parsed.Path += "/" } client.BaseURL = parsed } return &Integration{client: client, options: options}, nil } // CreateIssue creates an issue in the tracker 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{} severityLabel := fmt.Sprintf("Severity: %s", event.Info.SeverityHolder.Severity.String()) if i.options.SeverityAsLabel && severityLabel != "" { labels = append(labels, severityLabel) } if label := i.options.IssueLabel; label != "" { labels = append(labels, label) } 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 nil, err } } if existingIssue == nil { req := &github.IssueRequest{ Title: &summary, Body: &description, Labels: &labels, Assignees: &[]string{i.options.Username}, } 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 nil, fmt.Errorf("error reopening issue %d: %s", *existingIssue.Number, err) } } req := &github.IssueComment{ 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 func (i *Integration) ShouldFilter(event *output.ResultEvent) bool { if i.options.AllowList != nil && i.options.AllowList.GetMatch(event) { return true } if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) { return true } return false } func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*github.Issue, error) { req := &github.SearchOptions{ Sort: "updated", Order: "desc", TextMatch: false, ListOptions: github.ListOptions{ Page: 1, PerPage: 100, }, } query := fmt.Sprintf(`is:issue repo:%s/%s "%s"`, i.options.Owner, i.options.ProjectName, title) for { issues, resp, err := i.client.Search.Issues(ctx, query, req) if err != nil { return nil, fmt.Errorf("error listing issues for %s, %s: %w", i.options.Owner, i.options.ProjectName, err) } for _, issue := range issues.Issues { if issue.Title != nil && *issue.Title == title { return &issue, nil } } if resp.NextPage <= req.Page || len(issues.Issues) == 0 { return nil, io.EOF } req.ListOptions = github.ListOptions{ Page: resp.NextPage, PerPage: 100, } } }