mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2025-12-17 17:35:28 +00:00
* chore: satisfy lints Signed-off-by: Dwi Siswanto <git@dw1.io> * ci(tests): migrate to golangci-lint v2 Signed-off-by: Dwi Siswanto <git@dw1.io> --------- Signed-off-by: Dwi Siswanto <git@dw1.io>
417 lines
11 KiB
Go
417 lines
11 KiB
Go
package linear
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/shurcooL/graphql"
|
|
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
|
"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/reporting/trackers/linear/jsonutil"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
|
"github.com/projectdiscovery/retryablehttp-go"
|
|
)
|
|
|
|
// Integration is a client for linear issue tracker integration
|
|
type Integration struct {
|
|
url string
|
|
httpclient *http.Client
|
|
options *Options
|
|
}
|
|
|
|
// Options contains the configuration options for linear issue tracker client
|
|
type Options struct {
|
|
// APIKey is the API key for linear account.
|
|
APIKey string `yaml:"api-key" validate:"required"`
|
|
|
|
// 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"`
|
|
|
|
// TeamID is the team id for the project
|
|
TeamID string `yaml:"team-id"`
|
|
// ProjectID is the project id for the project
|
|
ProjectID string `yaml:"project-id"`
|
|
// DuplicateIssueCheck is a bool to enable duplicate tracking issue check and update the newest
|
|
DuplicateIssueCheck bool `yaml:"duplicate-issue-check" default:"false"`
|
|
|
|
// OpenStateID is the id of the open state for the project
|
|
OpenStateID string `yaml:"open-state-id"`
|
|
|
|
HttpClient *retryablehttp.Client `yaml:"-"`
|
|
OmitRaw bool `yaml:"-"`
|
|
}
|
|
|
|
// New creates a new issue tracker integration client based on options.
|
|
func New(options *Options) (*Integration, error) {
|
|
var transport = http.DefaultTransport
|
|
if options.HttpClient != nil && options.HttpClient.HTTPClient.Transport != nil {
|
|
transport = options.HttpClient.HTTPClient.Transport
|
|
}
|
|
|
|
httpClient := &http.Client{
|
|
Transport: &addHeaderTransport{
|
|
T: transport,
|
|
Key: options.APIKey,
|
|
},
|
|
}
|
|
|
|
integration := &Integration{
|
|
url: "https://api.linear.app/graphql",
|
|
options: options,
|
|
httpclient: httpClient,
|
|
}
|
|
|
|
return integration, 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)
|
|
_ = description
|
|
|
|
ctx := context.Background()
|
|
|
|
var err error
|
|
var existingIssue *linearIssue
|
|
if i.options.DuplicateIssueCheck {
|
|
existingIssue, err = i.findIssueByTitle(ctx, summary)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if existingIssue == nil {
|
|
// Create a new issue
|
|
createdIssue, err := i.createIssueLinear(ctx, summary, description, priorityFromSeverity(event.Info.SeverityHolder.Severity))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &filters.CreateIssueResponse{
|
|
IssueID: types.ToString(createdIssue.ID),
|
|
IssueURL: types.ToString(createdIssue.URL),
|
|
}, nil
|
|
} else {
|
|
if existingIssue.State.Name == "Done" {
|
|
// Update the issue state to open
|
|
var issueUpdateInput struct {
|
|
StateID string `json:"stateId"`
|
|
}
|
|
issueUpdateInput.StateID = i.options.OpenStateID
|
|
variables := map[string]interface{}{
|
|
"issueUpdateInput": issueUpdateInput,
|
|
"issueID": types.ToString(existingIssue.ID),
|
|
}
|
|
var resp struct {
|
|
IssueUpdate struct {
|
|
LastSyncID int `json:"lastSyncId"`
|
|
}
|
|
}
|
|
err := i.doGraphqlRequest(ctx, existingIssueUpdateStateMutation, &resp, variables, "IssueUpdate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reopening issue %s: %s", existingIssue.ID, err)
|
|
}
|
|
}
|
|
|
|
commentInput := map[string]interface{}{
|
|
"issueId": types.ToString(existingIssue.ID),
|
|
"body": description,
|
|
}
|
|
variables := map[string]interface{}{
|
|
"commentCreateInput": commentInput,
|
|
}
|
|
var resp struct {
|
|
CommentCreate struct {
|
|
LastSyncID int `json:"lastSyncId"`
|
|
}
|
|
}
|
|
err := i.doGraphqlRequest(ctx, commentCreateExistingTicketMutation, &resp, variables, "CommentCreate")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error commenting on issue %s: %s", existingIssue.ID, err)
|
|
}
|
|
return &filters.CreateIssueResponse{
|
|
IssueID: types.ToString(existingIssue.ID),
|
|
IssueURL: types.ToString(existingIssue.URL),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func priorityFromSeverity(sev severity.Severity) float64 {
|
|
switch sev {
|
|
case severity.Critical:
|
|
return linearPriorityCritical
|
|
case severity.High:
|
|
return linearPriorityHigh
|
|
case severity.Medium:
|
|
return linearPriorityMedium
|
|
case severity.Low:
|
|
return linearPriorityLow
|
|
default:
|
|
return linearPriorityNone
|
|
}
|
|
}
|
|
|
|
type createIssueMutation struct {
|
|
IssueCreate struct {
|
|
Issue struct {
|
|
ID graphql.ID
|
|
Title graphql.String
|
|
Identifier graphql.String
|
|
State struct {
|
|
Name graphql.String
|
|
}
|
|
URL graphql.String
|
|
}
|
|
}
|
|
}
|
|
|
|
const (
|
|
createIssueGraphQLMutation = `mutation CreateIssue($input: IssueCreateInput!) {
|
|
issueCreate(input: $input) {
|
|
issue {
|
|
id
|
|
title
|
|
identifier
|
|
state {
|
|
name
|
|
}
|
|
url
|
|
}
|
|
}
|
|
}`
|
|
|
|
searchExistingTicketQuery = `query ($teamID: ID, $projectID: ID, $title: String!) {
|
|
issues(filter: {
|
|
title: { eq: $title },
|
|
team: { id: { eq: $teamID } }
|
|
project: { id: { eq: $projectID } }
|
|
}) {
|
|
nodes {
|
|
id
|
|
title
|
|
identifier
|
|
state {
|
|
name
|
|
}
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
existingIssueUpdateStateMutation = `mutation IssueUpdate($issueUpdateInput: IssueUpdateInput!, $issueID: String!) {
|
|
issueUpdate(input: $issueUpdateInput, id: $issueID) {
|
|
lastSyncId
|
|
}
|
|
}
|
|
`
|
|
|
|
commentCreateExistingTicketMutation = `mutation CommentCreate($commentCreateInput: CommentCreateInput!) {
|
|
commentCreate(input: $commentCreateInput) {
|
|
lastSyncId
|
|
}
|
|
}
|
|
`
|
|
)
|
|
|
|
func (i *Integration) createIssueLinear(ctx context.Context, title, description string, priority float64) (*linearIssue, error) {
|
|
var mutation createIssueMutation
|
|
input := map[string]interface{}{
|
|
"title": title,
|
|
"description": description,
|
|
"priority": priority,
|
|
}
|
|
if i.options.TeamID != "" {
|
|
input["teamId"] = graphql.ID(i.options.TeamID)
|
|
}
|
|
if i.options.ProjectID != "" {
|
|
input["projectId"] = i.options.ProjectID
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"input": input,
|
|
}
|
|
|
|
err := i.doGraphqlRequest(ctx, createIssueGraphQLMutation, &mutation, variables, "CreateIssue")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &linearIssue{
|
|
ID: mutation.IssueCreate.Issue.ID,
|
|
Title: mutation.IssueCreate.Issue.Title,
|
|
Identifier: mutation.IssueCreate.Issue.Identifier,
|
|
State: struct {
|
|
Name graphql.String
|
|
}{
|
|
Name: mutation.IssueCreate.Issue.State.Name,
|
|
},
|
|
URL: mutation.IssueCreate.Issue.URL,
|
|
}, nil
|
|
}
|
|
|
|
func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*linearIssue, error) {
|
|
var query findExistingIssuesSearch
|
|
variables := map[string]interface{}{
|
|
"title": graphql.String(title),
|
|
}
|
|
if i.options.TeamID != "" {
|
|
variables["teamId"] = graphql.ID(i.options.TeamID)
|
|
}
|
|
if i.options.ProjectID != "" {
|
|
variables["projectID"] = graphql.ID(i.options.ProjectID)
|
|
}
|
|
|
|
err := i.doGraphqlRequest(ctx, searchExistingTicketQuery, &query, variables, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(query.Issues.Nodes) > 0 {
|
|
return &query.Issues.Nodes[0], nil
|
|
}
|
|
return nil, io.EOF
|
|
}
|
|
|
|
func (i *Integration) Name() string {
|
|
return "linear"
|
|
}
|
|
|
|
func (i *Integration) CloseIssue(event *output.ResultEvent) error {
|
|
// TODO: Unimplemented for now as not used in many places
|
|
// and overhead of maintaining our own API for this.
|
|
// This is too much code as it is :(
|
|
return nil
|
|
}
|
|
|
|
// 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 false
|
|
}
|
|
|
|
if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
type linearIssue struct {
|
|
ID graphql.ID
|
|
Title graphql.String
|
|
Identifier graphql.String
|
|
State struct {
|
|
Name graphql.String
|
|
}
|
|
URL graphql.String
|
|
}
|
|
|
|
type findExistingIssuesSearch struct {
|
|
Issues struct {
|
|
Nodes []linearIssue
|
|
}
|
|
}
|
|
|
|
// Custom transport to add the API key to the header
|
|
type addHeaderTransport struct {
|
|
T http.RoundTripper
|
|
Key string
|
|
}
|
|
|
|
func (adt *addHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req.Header.Add("Authorization", adt.Key)
|
|
return adt.T.RoundTrip(req)
|
|
}
|
|
|
|
const (
|
|
linearPriorityNone = float64(0)
|
|
linearPriorityCritical = float64(1)
|
|
linearPriorityHigh = float64(2)
|
|
linearPriorityMedium = float64(3)
|
|
linearPriorityLow = float64(4)
|
|
)
|
|
|
|
// errors represents the "errors" array in a response from a GraphQL server.
|
|
// If returned via error interface, the slice is expected to contain at least 1 element.
|
|
//
|
|
// Specification: https://spec.graphql.org/October2021/#sec-Errors.
|
|
type errorsGraphql []struct {
|
|
Message string
|
|
Locations []struct {
|
|
Line int
|
|
Column int
|
|
}
|
|
}
|
|
|
|
// Error implements error interface.
|
|
func (e errorsGraphql) Error() string {
|
|
return e[0].Message
|
|
}
|
|
|
|
// do executes a single GraphQL operation.
|
|
func (i *Integration) doGraphqlRequest(ctx context.Context, query string, v any, variables map[string]any, operationName string) error {
|
|
in := struct {
|
|
Query string `json:"query"`
|
|
Variables map[string]any `json:"variables,omitempty"`
|
|
OperationName string `json:"operationName,omitempty"`
|
|
}{
|
|
Query: query,
|
|
Variables: variables,
|
|
OperationName: operationName,
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err := json.NewEncoder(&buf).Encode(in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, i.url, &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := i.httpclient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body)
|
|
}
|
|
var out struct {
|
|
Data *json.Message
|
|
Errors errorsGraphql
|
|
//Extensions any // Unused.
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out.Data != nil {
|
|
err := jsonutil.UnmarshalGraphQL(*out.Data, v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(out.Errors) > 0 {
|
|
return out.Errors
|
|
}
|
|
return nil
|
|
}
|