mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2025-12-17 19:15:25 +00:00
feat: added linear issue tracker support to nuclei (#5601)
* feat: added linear issue tracker support to nuclei * misc * feat: fixed unmarshal issues * added linear config --------- Co-authored-by: sandeep <8293321+ehsandeep@users.noreply.github.com>
This commit is contained in:
parent
fcdead9157
commit
59e69b561d
@ -143,3 +143,22 @@
|
|||||||
# username: test
|
# username: test
|
||||||
# # Password is the password for elasticsearch instance
|
# # Password is the password for elasticsearch instance
|
||||||
# password: test
|
# password: test
|
||||||
|
#linear:
|
||||||
|
# # api-key is the API key for the linear account
|
||||||
|
# api-key: ""
|
||||||
|
# # allow-list sets a tracker level filter to only create issues for templates with
|
||||||
|
# # these severity labels or tags (does not affect exporters. set those globally)
|
||||||
|
# deny-list:
|
||||||
|
# severity: critical
|
||||||
|
# # deny-list sets a tracker level filter to never create issues for templates with
|
||||||
|
# # these severity labels or tags (does not affect exporters. set those globally)
|
||||||
|
# deny-list:
|
||||||
|
# severity: low
|
||||||
|
# # team-id is the ID of the team in Linear
|
||||||
|
# team-id: ""
|
||||||
|
# # project-id is the ID of the project in Linear
|
||||||
|
# project-id: ""
|
||||||
|
# # duplicate-issue-check flag to enable duplicate tracking issue check
|
||||||
|
# duplicate-issue-check: false
|
||||||
|
# # open-state-id is the ID of the open state in Linear
|
||||||
|
# open-state-id: ""
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -101,6 +101,7 @@ require (
|
|||||||
github.com/projectdiscovery/wappalyzergo v0.1.14
|
github.com/projectdiscovery/wappalyzergo v0.1.14
|
||||||
github.com/redis/go-redis/v9 v9.1.0
|
github.com/redis/go-redis/v9 v9.1.0
|
||||||
github.com/seh-msft/burpxml v1.0.1
|
github.com/seh-msft/burpxml v1.0.1
|
||||||
|
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.9.0
|
||||||
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9
|
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9
|
||||||
github.com/zmap/zgrab2 v0.1.8-0.20230806160807-97ba87c0e706
|
github.com/zmap/zgrab2 v0.1.8-0.20230806160807-97ba87c0e706
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -966,6 +966,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
|
|||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
|
||||||
|
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/github"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/github"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/gitlab"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/gitlab"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/jira"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/jira"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/linear"
|
||||||
"github.com/projectdiscovery/retryablehttp-go"
|
"github.com/projectdiscovery/retryablehttp-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,6 +30,8 @@ type Options struct {
|
|||||||
Gitea *gitea.Options `yaml:"gitea"`
|
Gitea *gitea.Options `yaml:"gitea"`
|
||||||
// Jira contains configuration options for Jira Issue Tracker
|
// Jira contains configuration options for Jira Issue Tracker
|
||||||
Jira *jira.Options `yaml:"jira"`
|
Jira *jira.Options `yaml:"jira"`
|
||||||
|
// Linear contains configuration options for Linear Issue Tracker
|
||||||
|
Linear *linear.Options `yaml:"linear"`
|
||||||
// MarkdownExporter contains configuration options for Markdown Exporter Module
|
// MarkdownExporter contains configuration options for Markdown Exporter Module
|
||||||
MarkdownExporter *markdown.Options `yaml:"markdown"`
|
MarkdownExporter *markdown.Options `yaml:"markdown"`
|
||||||
// SarifExporter contains configuration options for Sarif Exporter Module
|
// SarifExporter contains configuration options for Sarif Exporter Module
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/github"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/github"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/gitlab"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/gitlab"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/jira"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/jira"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/linear"
|
||||||
errorutil "github.com/projectdiscovery/utils/errors"
|
errorutil "github.com/projectdiscovery/utils/errors"
|
||||||
fileutil "github.com/projectdiscovery/utils/file"
|
fileutil "github.com/projectdiscovery/utils/file"
|
||||||
)
|
)
|
||||||
@ -112,6 +113,15 @@ func New(options *Options, db string, doNotDedupe bool) (Client, error) {
|
|||||||
}
|
}
|
||||||
client.trackers = append(client.trackers, tracker)
|
client.trackers = append(client.trackers, tracker)
|
||||||
}
|
}
|
||||||
|
if options.Linear != nil {
|
||||||
|
options.Linear.HttpClient = options.HttpClient
|
||||||
|
options.Linear.OmitRaw = options.OmitRaw
|
||||||
|
tracker, err := linear.New(options.Linear)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errorutil.NewWithErr(err).Wrap(ErrReportingClientCreation)
|
||||||
|
}
|
||||||
|
client.trackers = append(client.trackers, tracker)
|
||||||
|
}
|
||||||
if options.MarkdownExporter != nil {
|
if options.MarkdownExporter != nil {
|
||||||
exporter, err := markdown.New(options.MarkdownExporter)
|
exporter, err := markdown.New(options.MarkdownExporter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -195,6 +205,7 @@ func CreateConfigIfNotExists() error {
|
|||||||
GitLab: &gitlab.Options{},
|
GitLab: &gitlab.Options{},
|
||||||
Gitea: &gitea.Options{},
|
Gitea: &gitea.Options{},
|
||||||
Jira: &jira.Options{},
|
Jira: &jira.Options{},
|
||||||
|
Linear: &linear.Options{},
|
||||||
MarkdownExporter: &markdown.Options{},
|
MarkdownExporter: &markdown.Options{},
|
||||||
SarifExporter: &sarif.Options{},
|
SarifExporter: &sarif.Options{},
|
||||||
ElasticsearchExporter: &es.Options{},
|
ElasticsearchExporter: &es.Options{},
|
||||||
|
|||||||
312
pkg/reporting/trackers/linear/jsonutil/jsonutil.go
Normal file
312
pkg/reporting/trackers/linear/jsonutil/jsonutil.go
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
// Package jsonutil provides a function for decoding JSON
|
||||||
|
// into a GraphQL query data structure.
|
||||||
|
//
|
||||||
|
// Taken from: https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/internal/jsonutil/graphql.go
|
||||||
|
package jsonutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnmarshalGraphQL parses the JSON-encoded GraphQL response data and stores
|
||||||
|
// the result in the GraphQL query data structure pointed to by v.
|
||||||
|
//
|
||||||
|
// The implementation is created on top of the JSON tokenizer available
|
||||||
|
// in "encoding/json".Decoder.
|
||||||
|
func UnmarshalGraphQL(data []byte, v any) error {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.UseNumber()
|
||||||
|
err := (&decoder{tokenizer: dec}).Decode(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tok, err := dec.Token()
|
||||||
|
switch err {
|
||||||
|
case io.EOF:
|
||||||
|
// Expect to get io.EOF. There shouldn't be any more
|
||||||
|
// tokens left after we've decoded v successfully.
|
||||||
|
return nil
|
||||||
|
case nil:
|
||||||
|
return fmt.Errorf("invalid token '%v' after top-level value", tok)
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decoder is a JSON decoder that performs custom unmarshaling behavior
|
||||||
|
// for GraphQL query data structures. It's implemented on top of a JSON tokenizer.
|
||||||
|
type decoder struct {
|
||||||
|
tokenizer interface {
|
||||||
|
Token() (json.Token, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack of what part of input JSON we're in the middle of - objects, arrays.
|
||||||
|
parseState []json.Delim
|
||||||
|
|
||||||
|
// Stacks of values where to unmarshal.
|
||||||
|
// The top of each stack is the reflect.Value where to unmarshal next JSON value.
|
||||||
|
//
|
||||||
|
// The reason there's more than one stack is because we might be unmarshaling
|
||||||
|
// a single JSON value into multiple GraphQL fragments or embedded structs, so
|
||||||
|
// we keep track of them all.
|
||||||
|
vs [][]reflect.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode decodes a single JSON value from d.tokenizer into v.
|
||||||
|
func (d *decoder) Decode(v any) error {
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
if rv.Kind() != reflect.Ptr {
|
||||||
|
return fmt.Errorf("cannot decode into non-pointer %T", v)
|
||||||
|
}
|
||||||
|
d.vs = [][]reflect.Value{{rv.Elem()}}
|
||||||
|
return d.decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode decodes a single JSON value from d.tokenizer into d.vs.
|
||||||
|
func (d *decoder) decode() error {
|
||||||
|
// The loop invariant is that the top of each d.vs stack
|
||||||
|
// is where we try to unmarshal the next JSON value we see.
|
||||||
|
for len(d.vs) > 0 {
|
||||||
|
tok, err := d.tokenizer.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
return errors.New("unexpected end of JSON input")
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
|
||||||
|
// Are we inside an object and seeing next key (rather than end of object)?
|
||||||
|
case d.state() == '{' && tok != json.Delim('}'):
|
||||||
|
key, ok := tok.(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("unexpected non-key in JSON input")
|
||||||
|
}
|
||||||
|
someFieldExist := false
|
||||||
|
for i := range d.vs {
|
||||||
|
v := d.vs[i][len(d.vs[i])-1]
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
var f reflect.Value
|
||||||
|
if v.Kind() == reflect.Struct {
|
||||||
|
f = fieldByGraphQLName(v, key)
|
||||||
|
if f.IsValid() {
|
||||||
|
someFieldExist = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.vs[i] = append(d.vs[i], f)
|
||||||
|
}
|
||||||
|
if !someFieldExist {
|
||||||
|
return fmt.Errorf("struct field for %q doesn't exist in any of %v places to unmarshal", key, len(d.vs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've just consumed the current token, which was the key.
|
||||||
|
// Read the next token, which should be the value, and let the rest of code process it.
|
||||||
|
tok, err = d.tokenizer.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
return errors.New("unexpected end of JSON input")
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we inside an array and seeing next value (rather than end of array)?
|
||||||
|
case d.state() == '[' && tok != json.Delim(']'):
|
||||||
|
someSliceExist := false
|
||||||
|
for i := range d.vs {
|
||||||
|
v := d.vs[i][len(d.vs[i])-1]
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
var f reflect.Value
|
||||||
|
if v.Kind() == reflect.Slice {
|
||||||
|
v.Set(reflect.Append(v, reflect.Zero(v.Type().Elem()))) // v = append(v, T).
|
||||||
|
f = v.Index(v.Len() - 1)
|
||||||
|
someSliceExist = true
|
||||||
|
}
|
||||||
|
d.vs[i] = append(d.vs[i], f)
|
||||||
|
}
|
||||||
|
if !someSliceExist {
|
||||||
|
return fmt.Errorf("slice doesn't exist in any of %v places to unmarshal", len(d.vs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tok := tok.(type) {
|
||||||
|
case string, json.Number, bool, nil:
|
||||||
|
// Value.
|
||||||
|
|
||||||
|
for i := range d.vs {
|
||||||
|
v := d.vs[i][len(d.vs[i])-1]
|
||||||
|
if !v.IsValid() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := unmarshalValue(tok, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.popAllVs()
|
||||||
|
|
||||||
|
case json.Delim:
|
||||||
|
switch tok {
|
||||||
|
case '{':
|
||||||
|
// Start of object.
|
||||||
|
|
||||||
|
d.pushState(tok)
|
||||||
|
|
||||||
|
frontier := make([]reflect.Value, len(d.vs)) // Places to look for GraphQL fragments/embedded structs.
|
||||||
|
for i := range d.vs {
|
||||||
|
v := d.vs[i][len(d.vs[i])-1]
|
||||||
|
frontier[i] = v
|
||||||
|
// TODO: Do this recursively or not? Add a test case if needed.
|
||||||
|
if v.Kind() == reflect.Ptr && v.IsNil() {
|
||||||
|
v.Set(reflect.New(v.Type().Elem())) // v = new(T).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find GraphQL fragments/embedded structs recursively, adding to frontier
|
||||||
|
// as new ones are discovered and exploring them further.
|
||||||
|
for len(frontier) > 0 {
|
||||||
|
v := frontier[0]
|
||||||
|
frontier = frontier[1:]
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
if isGraphQLFragment(v.Type().Field(i)) || v.Type().Field(i).Anonymous {
|
||||||
|
// Add GraphQL fragment or embedded struct.
|
||||||
|
d.vs = append(d.vs, []reflect.Value{v.Field(i)})
|
||||||
|
frontier = append(frontier, v.Field(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case '[':
|
||||||
|
// Start of array.
|
||||||
|
|
||||||
|
d.pushState(tok)
|
||||||
|
|
||||||
|
for i := range d.vs {
|
||||||
|
v := d.vs[i][len(d.vs[i])-1]
|
||||||
|
// TODO: Confirm this is needed, write a test case.
|
||||||
|
//if v.Kind() == reflect.Ptr && v.IsNil() {
|
||||||
|
// v.Set(reflect.New(v.Type().Elem())) // v = new(T).
|
||||||
|
//}
|
||||||
|
|
||||||
|
// Reset slice to empty (in case it had non-zero initial value).
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if v.Kind() != reflect.Slice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v.Set(reflect.MakeSlice(v.Type(), 0, 0)) // v = make(T, 0, 0).
|
||||||
|
}
|
||||||
|
case '}', ']':
|
||||||
|
// End of object or array.
|
||||||
|
d.popAllVs()
|
||||||
|
d.popState()
|
||||||
|
default:
|
||||||
|
return errors.New("unexpected delimiter in JSON input")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("unexpected token in JSON input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushState pushes a new parse state s onto the stack.
|
||||||
|
func (d *decoder) pushState(s json.Delim) {
|
||||||
|
d.parseState = append(d.parseState, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// popState pops a parse state (already obtained) off the stack.
|
||||||
|
// The stack must be non-empty.
|
||||||
|
func (d *decoder) popState() {
|
||||||
|
d.parseState = d.parseState[:len(d.parseState)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// state reports the parse state on top of stack, or 0 if empty.
|
||||||
|
func (d *decoder) state() json.Delim {
|
||||||
|
if len(d.parseState) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return d.parseState[len(d.parseState)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// popAllVs pops from all d.vs stacks, keeping only non-empty ones.
|
||||||
|
func (d *decoder) popAllVs() {
|
||||||
|
var nonEmpty [][]reflect.Value
|
||||||
|
for i := range d.vs {
|
||||||
|
d.vs[i] = d.vs[i][:len(d.vs[i])-1]
|
||||||
|
if len(d.vs[i]) > 0 {
|
||||||
|
nonEmpty = append(nonEmpty, d.vs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.vs = nonEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldByGraphQLName returns an exported struct field of struct v
|
||||||
|
// that matches GraphQL name, or invalid reflect.Value if none found.
|
||||||
|
func fieldByGraphQLName(v reflect.Value, name string) reflect.Value {
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
if v.Type().Field(i).PkgPath != "" {
|
||||||
|
// Skip unexported field.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasGraphQLName(v.Type().Field(i), name) {
|
||||||
|
return v.Field(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reflect.Value{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGraphQLName reports whether struct field f has GraphQL name.
|
||||||
|
func hasGraphQLName(f reflect.StructField, name string) bool {
|
||||||
|
value, ok := f.Tag.Lookup("graphql")
|
||||||
|
if !ok {
|
||||||
|
// TODO: caseconv package is relatively slow. Optimize it, then consider using it here.
|
||||||
|
//return caseconv.MixedCapsToLowerCamelCase(f.Name) == name
|
||||||
|
return strings.EqualFold(f.Name, name)
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value) // TODO: Parse better.
|
||||||
|
if strings.HasPrefix(value, "...") {
|
||||||
|
// GraphQL fragment. It doesn't have a name.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Cut off anything that follows the field name,
|
||||||
|
// such as field arguments, aliases, directives.
|
||||||
|
if i := strings.IndexAny(value, "(:@"); i != -1 {
|
||||||
|
value = value[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value) == name
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGraphQLFragment reports whether struct field f is a GraphQL fragment.
|
||||||
|
func isGraphQLFragment(f reflect.StructField) bool {
|
||||||
|
value, ok := f.Tag.Lookup("graphql")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value) // TODO: Parse better.
|
||||||
|
return strings.HasPrefix(value, "...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalValue unmarshals JSON value into v.
|
||||||
|
// v must be addressable and not obtained by the use of unexported
|
||||||
|
// struct fields, otherwise unmarshalValue will panic.
|
||||||
|
func unmarshalValue(value json.Token, v reflect.Value) error {
|
||||||
|
b, err := json.Marshal(value) // TODO: Short-circuit (if profiling says it's worth it).
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v.Addr().Interface())
|
||||||
|
}
|
||||||
404
pkg/reporting/trackers/linear/linear.go
Normal file
404
pkg/reporting/trackers/linear/linear.go
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
package linear
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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/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) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &addHeaderTransport{
|
||||||
|
T: http.DefaultTransport,
|
||||||
|
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 {
|
||||||
|
LastSyncID string `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 {
|
||||||
|
LastSyncID string `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 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.RawMessage
|
||||||
|
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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user