Path: blob/dev/pkg/reporting/trackers/linear/linear.go
2070 views
package linear12import (3"bytes"4"context"5"errors"6"fmt"7"io"8"net/http"910"github.com/shurcooL/graphql"1112"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"13"github.com/projectdiscovery/nuclei/v3/pkg/output"14"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"15"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"16"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"17"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/linear/jsonutil"18"github.com/projectdiscovery/nuclei/v3/pkg/types"19"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"20"github.com/projectdiscovery/retryablehttp-go"21)2223// Integration is a client for linear issue tracker integration24type Integration struct {25url string26httpclient *http.Client27options *Options28}2930// Options contains the configuration options for linear issue tracker client31type Options struct {32// APIKey is the API key for linear account.33APIKey string `yaml:"api-key" validate:"required"`3435// AllowList contains a list of allowed events for this tracker36AllowList *filters.Filter `yaml:"allow-list"`37// DenyList contains a list of denied events for this tracker38DenyList *filters.Filter `yaml:"deny-list"`3940// TeamID is the team id for the project41TeamID string `yaml:"team-id"`42// ProjectID is the project id for the project43ProjectID string `yaml:"project-id"`44// DuplicateIssueCheck is a bool to enable duplicate tracking issue check and update the newest45DuplicateIssueCheck bool `yaml:"duplicate-issue-check" default:"false"`4647// OpenStateID is the id of the open state for the project48OpenStateID string `yaml:"open-state-id"`4950HttpClient *retryablehttp.Client `yaml:"-"`51OmitRaw bool `yaml:"-"`52}5354// New creates a new issue tracker integration client based on options.55func New(options *Options) (*Integration, error) {56var transport = http.DefaultTransport57if options.HttpClient != nil && options.HttpClient.HTTPClient.Transport != nil {58transport = options.HttpClient.HTTPClient.Transport59}6061httpClient := &http.Client{62Transport: &addHeaderTransport{63T: transport,64Key: options.APIKey,65},66}6768integration := &Integration{69url: "https://api.linear.app/graphql",70options: options,71httpclient: httpClient,72}7374return integration, nil75}7677// CreateIssue creates an issue in the tracker78func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {79summary := format.Summary(event)80description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw)81_ = description8283ctx := context.Background()8485var err error86var existingIssue *linearIssue87if i.options.DuplicateIssueCheck {88existingIssue, err = i.findIssueByTitle(ctx, summary)89if err != nil && !errors.Is(err, io.EOF) {90return nil, err91}92}9394if existingIssue == nil {95// Create a new issue96createdIssue, err := i.createIssueLinear(ctx, summary, description, priorityFromSeverity(event.Info.SeverityHolder.Severity))97if err != nil {98return nil, err99}100return &filters.CreateIssueResponse{101IssueID: types.ToString(createdIssue.ID),102IssueURL: types.ToString(createdIssue.URL),103}, nil104} else {105if existingIssue.State.Name == "Done" {106// Update the issue state to open107var issueUpdateInput struct {108StateID string `json:"stateId"`109}110issueUpdateInput.StateID = i.options.OpenStateID111variables := map[string]interface{}{112"issueUpdateInput": issueUpdateInput,113"issueID": types.ToString(existingIssue.ID),114}115var resp struct {116IssueUpdate struct {117LastSyncID int `json:"lastSyncId"`118}119}120err := i.doGraphqlRequest(ctx, existingIssueUpdateStateMutation, &resp, variables, "IssueUpdate")121if err != nil {122return nil, fmt.Errorf("error reopening issue %s: %s", existingIssue.ID, err)123}124}125126commentInput := map[string]interface{}{127"issueId": types.ToString(existingIssue.ID),128"body": description,129}130variables := map[string]interface{}{131"commentCreateInput": commentInput,132}133var resp struct {134CommentCreate struct {135LastSyncID int `json:"lastSyncId"`136}137}138err := i.doGraphqlRequest(ctx, commentCreateExistingTicketMutation, &resp, variables, "CommentCreate")139if err != nil {140return nil, fmt.Errorf("error commenting on issue %s: %s", existingIssue.ID, err)141}142return &filters.CreateIssueResponse{143IssueID: types.ToString(existingIssue.ID),144IssueURL: types.ToString(existingIssue.URL),145}, nil146}147}148149func priorityFromSeverity(sev severity.Severity) float64 {150switch sev {151case severity.Critical:152return linearPriorityCritical153case severity.High:154return linearPriorityHigh155case severity.Medium:156return linearPriorityMedium157case severity.Low:158return linearPriorityLow159default:160return linearPriorityNone161}162}163164type createIssueMutation struct {165IssueCreate struct {166Issue struct {167ID graphql.ID168Title graphql.String169Identifier graphql.String170State struct {171Name graphql.String172}173URL graphql.String174}175}176}177178const (179createIssueGraphQLMutation = `mutation CreateIssue($input: IssueCreateInput!) {180issueCreate(input: $input) {181issue {182id183title184identifier185state {186name187}188url189}190}191}`192193searchExistingTicketQuery = `query ($teamID: ID, $projectID: ID, $title: String!) {194issues(filter: {195title: { eq: $title },196team: { id: { eq: $teamID } }197project: { id: { eq: $projectID } }198}) {199nodes {200id201title202identifier203state {204name205}206url207}208}209}210`211212existingIssueUpdateStateMutation = `mutation IssueUpdate($issueUpdateInput: IssueUpdateInput!, $issueID: String!) {213issueUpdate(input: $issueUpdateInput, id: $issueID) {214lastSyncId215}216}217`218219commentCreateExistingTicketMutation = `mutation CommentCreate($commentCreateInput: CommentCreateInput!) {220commentCreate(input: $commentCreateInput) {221lastSyncId222}223}224`225)226227func (i *Integration) createIssueLinear(ctx context.Context, title, description string, priority float64) (*linearIssue, error) {228var mutation createIssueMutation229input := map[string]interface{}{230"title": title,231"description": description,232"priority": priority,233}234if i.options.TeamID != "" {235input["teamId"] = graphql.ID(i.options.TeamID)236}237if i.options.ProjectID != "" {238input["projectId"] = i.options.ProjectID239}240241variables := map[string]interface{}{242"input": input,243}244245err := i.doGraphqlRequest(ctx, createIssueGraphQLMutation, &mutation, variables, "CreateIssue")246if err != nil {247return nil, err248}249250return &linearIssue{251ID: mutation.IssueCreate.Issue.ID,252Title: mutation.IssueCreate.Issue.Title,253Identifier: mutation.IssueCreate.Issue.Identifier,254State: struct {255Name graphql.String256}{257Name: mutation.IssueCreate.Issue.State.Name,258},259URL: mutation.IssueCreate.Issue.URL,260}, nil261}262263func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*linearIssue, error) {264var query findExistingIssuesSearch265variables := map[string]interface{}{266"title": graphql.String(title),267}268if i.options.TeamID != "" {269variables["teamId"] = graphql.ID(i.options.TeamID)270}271if i.options.ProjectID != "" {272variables["projectID"] = graphql.ID(i.options.ProjectID)273}274275err := i.doGraphqlRequest(ctx, searchExistingTicketQuery, &query, variables, "")276if err != nil {277return nil, err278}279280if len(query.Issues.Nodes) > 0 {281return &query.Issues.Nodes[0], nil282}283return nil, io.EOF284}285286func (i *Integration) Name() string {287return "linear"288}289290func (i *Integration) CloseIssue(event *output.ResultEvent) error {291// TODO: Unimplemented for now as not used in many places292// and overhead of maintaining our own API for this.293// This is too much code as it is :(294return nil295}296297// ShouldFilter determines if an issue should be logged to this tracker298func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {299if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {300return false301}302303if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {304return false305}306307return true308}309310type linearIssue struct {311ID graphql.ID312Title graphql.String313Identifier graphql.String314State struct {315Name graphql.String316}317URL graphql.String318}319320type findExistingIssuesSearch struct {321Issues struct {322Nodes []linearIssue323}324}325326// Custom transport to add the API key to the header327type addHeaderTransport struct {328T http.RoundTripper329Key string330}331332func (adt *addHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {333req.Header.Add("Authorization", adt.Key)334return adt.T.RoundTrip(req)335}336337const (338linearPriorityNone = float64(0)339linearPriorityCritical = float64(1)340linearPriorityHigh = float64(2)341linearPriorityMedium = float64(3)342linearPriorityLow = float64(4)343)344345// errors represents the "errors" array in a response from a GraphQL server.346// If returned via error interface, the slice is expected to contain at least 1 element.347//348// Specification: https://spec.graphql.org/October2021/#sec-Errors.349type errorsGraphql []struct {350Message string351Locations []struct {352Line int353Column int354}355}356357// Error implements error interface.358func (e errorsGraphql) Error() string {359return e[0].Message360}361362// do executes a single GraphQL operation.363func (i *Integration) doGraphqlRequest(ctx context.Context, query string, v any, variables map[string]any, operationName string) error {364in := struct {365Query string `json:"query"`366Variables map[string]any `json:"variables,omitempty"`367OperationName string `json:"operationName,omitempty"`368}{369Query: query,370Variables: variables,371OperationName: operationName,372}373374var buf bytes.Buffer375err := json.NewEncoder(&buf).Encode(in)376if err != nil {377return err378}379req, err := http.NewRequestWithContext(ctx, http.MethodPost, i.url, &buf)380if err != nil {381return err382}383req.Header.Set("Content-Type", "application/json")384resp, err := i.httpclient.Do(req)385if err != nil {386return err387}388defer func() {389_ = resp.Body.Close()390}()391if resp.StatusCode != http.StatusOK {392body, _ := io.ReadAll(resp.Body)393return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body)394}395var out struct {396Data *json.Message397Errors errorsGraphql398//Extensions any // Unused.399}400401err = json.NewDecoder(resp.Body).Decode(&out)402if err != nil {403return err404}405if out.Data != nil {406err := jsonutil.UnmarshalGraphQL(*out.Data, v)407if err != nil {408return err409}410}411if len(out.Errors) > 0 {412return out.Errors413}414return nil415}416417418