Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
projectdiscovery
GitHub Repository: projectdiscovery/nuclei
Path: blob/dev/pkg/reporting/trackers/linear/linear.go
2070 views
1
package linear
2
3
import (
4
"bytes"
5
"context"
6
"errors"
7
"fmt"
8
"io"
9
"net/http"
10
11
"github.com/shurcooL/graphql"
12
13
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
14
"github.com/projectdiscovery/nuclei/v3/pkg/output"
15
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
16
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"
17
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
18
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/linear/jsonutil"
19
"github.com/projectdiscovery/nuclei/v3/pkg/types"
20
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
21
"github.com/projectdiscovery/retryablehttp-go"
22
)
23
24
// Integration is a client for linear issue tracker integration
25
type Integration struct {
26
url string
27
httpclient *http.Client
28
options *Options
29
}
30
31
// Options contains the configuration options for linear issue tracker client
32
type Options struct {
33
// APIKey is the API key for linear account.
34
APIKey string `yaml:"api-key" validate:"required"`
35
36
// AllowList contains a list of allowed events for this tracker
37
AllowList *filters.Filter `yaml:"allow-list"`
38
// DenyList contains a list of denied events for this tracker
39
DenyList *filters.Filter `yaml:"deny-list"`
40
41
// TeamID is the team id for the project
42
TeamID string `yaml:"team-id"`
43
// ProjectID is the project id for the project
44
ProjectID string `yaml:"project-id"`
45
// DuplicateIssueCheck is a bool to enable duplicate tracking issue check and update the newest
46
DuplicateIssueCheck bool `yaml:"duplicate-issue-check" default:"false"`
47
48
// OpenStateID is the id of the open state for the project
49
OpenStateID string `yaml:"open-state-id"`
50
51
HttpClient *retryablehttp.Client `yaml:"-"`
52
OmitRaw bool `yaml:"-"`
53
}
54
55
// New creates a new issue tracker integration client based on options.
56
func New(options *Options) (*Integration, error) {
57
var transport = http.DefaultTransport
58
if options.HttpClient != nil && options.HttpClient.HTTPClient.Transport != nil {
59
transport = options.HttpClient.HTTPClient.Transport
60
}
61
62
httpClient := &http.Client{
63
Transport: &addHeaderTransport{
64
T: transport,
65
Key: options.APIKey,
66
},
67
}
68
69
integration := &Integration{
70
url: "https://api.linear.app/graphql",
71
options: options,
72
httpclient: httpClient,
73
}
74
75
return integration, nil
76
}
77
78
// CreateIssue creates an issue in the tracker
79
func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
80
summary := format.Summary(event)
81
description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw)
82
_ = description
83
84
ctx := context.Background()
85
86
var err error
87
var existingIssue *linearIssue
88
if i.options.DuplicateIssueCheck {
89
existingIssue, err = i.findIssueByTitle(ctx, summary)
90
if err != nil && !errors.Is(err, io.EOF) {
91
return nil, err
92
}
93
}
94
95
if existingIssue == nil {
96
// Create a new issue
97
createdIssue, err := i.createIssueLinear(ctx, summary, description, priorityFromSeverity(event.Info.SeverityHolder.Severity))
98
if err != nil {
99
return nil, err
100
}
101
return &filters.CreateIssueResponse{
102
IssueID: types.ToString(createdIssue.ID),
103
IssueURL: types.ToString(createdIssue.URL),
104
}, nil
105
} else {
106
if existingIssue.State.Name == "Done" {
107
// Update the issue state to open
108
var issueUpdateInput struct {
109
StateID string `json:"stateId"`
110
}
111
issueUpdateInput.StateID = i.options.OpenStateID
112
variables := map[string]interface{}{
113
"issueUpdateInput": issueUpdateInput,
114
"issueID": types.ToString(existingIssue.ID),
115
}
116
var resp struct {
117
IssueUpdate struct {
118
LastSyncID int `json:"lastSyncId"`
119
}
120
}
121
err := i.doGraphqlRequest(ctx, existingIssueUpdateStateMutation, &resp, variables, "IssueUpdate")
122
if err != nil {
123
return nil, fmt.Errorf("error reopening issue %s: %s", existingIssue.ID, err)
124
}
125
}
126
127
commentInput := map[string]interface{}{
128
"issueId": types.ToString(existingIssue.ID),
129
"body": description,
130
}
131
variables := map[string]interface{}{
132
"commentCreateInput": commentInput,
133
}
134
var resp struct {
135
CommentCreate struct {
136
LastSyncID int `json:"lastSyncId"`
137
}
138
}
139
err := i.doGraphqlRequest(ctx, commentCreateExistingTicketMutation, &resp, variables, "CommentCreate")
140
if err != nil {
141
return nil, fmt.Errorf("error commenting on issue %s: %s", existingIssue.ID, err)
142
}
143
return &filters.CreateIssueResponse{
144
IssueID: types.ToString(existingIssue.ID),
145
IssueURL: types.ToString(existingIssue.URL),
146
}, nil
147
}
148
}
149
150
func priorityFromSeverity(sev severity.Severity) float64 {
151
switch sev {
152
case severity.Critical:
153
return linearPriorityCritical
154
case severity.High:
155
return linearPriorityHigh
156
case severity.Medium:
157
return linearPriorityMedium
158
case severity.Low:
159
return linearPriorityLow
160
default:
161
return linearPriorityNone
162
}
163
}
164
165
type createIssueMutation struct {
166
IssueCreate struct {
167
Issue struct {
168
ID graphql.ID
169
Title graphql.String
170
Identifier graphql.String
171
State struct {
172
Name graphql.String
173
}
174
URL graphql.String
175
}
176
}
177
}
178
179
const (
180
createIssueGraphQLMutation = `mutation CreateIssue($input: IssueCreateInput!) {
181
issueCreate(input: $input) {
182
issue {
183
id
184
title
185
identifier
186
state {
187
name
188
}
189
url
190
}
191
}
192
}`
193
194
searchExistingTicketQuery = `query ($teamID: ID, $projectID: ID, $title: String!) {
195
issues(filter: {
196
title: { eq: $title },
197
team: { id: { eq: $teamID } }
198
project: { id: { eq: $projectID } }
199
}) {
200
nodes {
201
id
202
title
203
identifier
204
state {
205
name
206
}
207
url
208
}
209
}
210
}
211
`
212
213
existingIssueUpdateStateMutation = `mutation IssueUpdate($issueUpdateInput: IssueUpdateInput!, $issueID: String!) {
214
issueUpdate(input: $issueUpdateInput, id: $issueID) {
215
lastSyncId
216
}
217
}
218
`
219
220
commentCreateExistingTicketMutation = `mutation CommentCreate($commentCreateInput: CommentCreateInput!) {
221
commentCreate(input: $commentCreateInput) {
222
lastSyncId
223
}
224
}
225
`
226
)
227
228
func (i *Integration) createIssueLinear(ctx context.Context, title, description string, priority float64) (*linearIssue, error) {
229
var mutation createIssueMutation
230
input := map[string]interface{}{
231
"title": title,
232
"description": description,
233
"priority": priority,
234
}
235
if i.options.TeamID != "" {
236
input["teamId"] = graphql.ID(i.options.TeamID)
237
}
238
if i.options.ProjectID != "" {
239
input["projectId"] = i.options.ProjectID
240
}
241
242
variables := map[string]interface{}{
243
"input": input,
244
}
245
246
err := i.doGraphqlRequest(ctx, createIssueGraphQLMutation, &mutation, variables, "CreateIssue")
247
if err != nil {
248
return nil, err
249
}
250
251
return &linearIssue{
252
ID: mutation.IssueCreate.Issue.ID,
253
Title: mutation.IssueCreate.Issue.Title,
254
Identifier: mutation.IssueCreate.Issue.Identifier,
255
State: struct {
256
Name graphql.String
257
}{
258
Name: mutation.IssueCreate.Issue.State.Name,
259
},
260
URL: mutation.IssueCreate.Issue.URL,
261
}, nil
262
}
263
264
func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*linearIssue, error) {
265
var query findExistingIssuesSearch
266
variables := map[string]interface{}{
267
"title": graphql.String(title),
268
}
269
if i.options.TeamID != "" {
270
variables["teamId"] = graphql.ID(i.options.TeamID)
271
}
272
if i.options.ProjectID != "" {
273
variables["projectID"] = graphql.ID(i.options.ProjectID)
274
}
275
276
err := i.doGraphqlRequest(ctx, searchExistingTicketQuery, &query, variables, "")
277
if err != nil {
278
return nil, err
279
}
280
281
if len(query.Issues.Nodes) > 0 {
282
return &query.Issues.Nodes[0], nil
283
}
284
return nil, io.EOF
285
}
286
287
func (i *Integration) Name() string {
288
return "linear"
289
}
290
291
func (i *Integration) CloseIssue(event *output.ResultEvent) error {
292
// TODO: Unimplemented for now as not used in many places
293
// and overhead of maintaining our own API for this.
294
// This is too much code as it is :(
295
return nil
296
}
297
298
// ShouldFilter determines if an issue should be logged to this tracker
299
func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {
300
if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {
301
return false
302
}
303
304
if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {
305
return false
306
}
307
308
return true
309
}
310
311
type linearIssue struct {
312
ID graphql.ID
313
Title graphql.String
314
Identifier graphql.String
315
State struct {
316
Name graphql.String
317
}
318
URL graphql.String
319
}
320
321
type findExistingIssuesSearch struct {
322
Issues struct {
323
Nodes []linearIssue
324
}
325
}
326
327
// Custom transport to add the API key to the header
328
type addHeaderTransport struct {
329
T http.RoundTripper
330
Key string
331
}
332
333
func (adt *addHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
334
req.Header.Add("Authorization", adt.Key)
335
return adt.T.RoundTrip(req)
336
}
337
338
const (
339
linearPriorityNone = float64(0)
340
linearPriorityCritical = float64(1)
341
linearPriorityHigh = float64(2)
342
linearPriorityMedium = float64(3)
343
linearPriorityLow = float64(4)
344
)
345
346
// errors represents the "errors" array in a response from a GraphQL server.
347
// If returned via error interface, the slice is expected to contain at least 1 element.
348
//
349
// Specification: https://spec.graphql.org/October2021/#sec-Errors.
350
type errorsGraphql []struct {
351
Message string
352
Locations []struct {
353
Line int
354
Column int
355
}
356
}
357
358
// Error implements error interface.
359
func (e errorsGraphql) Error() string {
360
return e[0].Message
361
}
362
363
// do executes a single GraphQL operation.
364
func (i *Integration) doGraphqlRequest(ctx context.Context, query string, v any, variables map[string]any, operationName string) error {
365
in := struct {
366
Query string `json:"query"`
367
Variables map[string]any `json:"variables,omitempty"`
368
OperationName string `json:"operationName,omitempty"`
369
}{
370
Query: query,
371
Variables: variables,
372
OperationName: operationName,
373
}
374
375
var buf bytes.Buffer
376
err := json.NewEncoder(&buf).Encode(in)
377
if err != nil {
378
return err
379
}
380
req, err := http.NewRequestWithContext(ctx, http.MethodPost, i.url, &buf)
381
if err != nil {
382
return err
383
}
384
req.Header.Set("Content-Type", "application/json")
385
resp, err := i.httpclient.Do(req)
386
if err != nil {
387
return err
388
}
389
defer func() {
390
_ = resp.Body.Close()
391
}()
392
if resp.StatusCode != http.StatusOK {
393
body, _ := io.ReadAll(resp.Body)
394
return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body)
395
}
396
var out struct {
397
Data *json.Message
398
Errors errorsGraphql
399
//Extensions any // Unused.
400
}
401
402
err = json.NewDecoder(resp.Body).Decode(&out)
403
if err != nil {
404
return err
405
}
406
if out.Data != nil {
407
err := jsonutil.UnmarshalGraphQL(*out.Data, v)
408
if err != nil {
409
return err
410
}
411
}
412
if len(out.Errors) > 0 {
413
return out.Errors
414
}
415
return nil
416
}
417
418