Skip to content

chore: Add graphql to the config for Github #103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ Under the hood, this config adds these allowlist items:
- POST `https://github.example.com/api/v3/app-manifests/:code/conversions`
- POST `https://github.example.com/api/v3/repos/:owner/:repo/pulls/:number/comments`
- POST `https://github.example.com/api/v3/repos/:owner/:repo/issues/:number/comments`
- POST `https://github.example.com/graphql`
- GraphQL Operations:
- Query
- GetBlameDetails
- Mutations
- resolveReviewThread
- unresolveReviewThread

And if `allowCodeAccess` is set, additionally:

Expand Down Expand Up @@ -194,7 +201,6 @@ And if `allowCodeAccess` is set, additionally:
- GET `https://bitbucket.example.com/rest/api/latest/projects/:project/repos/:repo/browse/:filepath`
- POST `https://bitbucket.example.com/rest/api/latest/projects/:project/repos/:repo/commit/:commit/builds`


### AzureDevops

Similarly, the `azuredevops` configuration section grants Semgrep access to azure devops.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ require (
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/graphql-go/graphql v0.8.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
Expand Down
93 changes: 93 additions & 0 deletions pkg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"reflect"
"strings"

"github.com/graphql-go/graphql/language/ast"
"github.com/graphql-go/graphql/language/parser"
log "github.com/sirupsen/logrus"

"github.com/mcuadros/go-defaults"
Expand Down Expand Up @@ -178,6 +180,75 @@ func httpMethodsDecodeHook(f reflect.Type, t reflect.Type, data interface{}) (in
return ParseHttpMethods(methods), nil
}

type graphQlRequest struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uber nit but let's have this be public like everything else (i.e. capital G)

Query string `json:"query"`
OperationName string `json:"operationName,omitempty"`
Variables map[string]interface{} `json:"variables,omitempty"`
}

func (config *InboundProxyConfig) validateGraphQLRequest(body []byte, filter *GraphQLFilter) error {
if filter == nil {
return nil
}

var req graphQlRequest
if err := json.Unmarshal(body, &req); err != nil {
return fmt.Errorf("invalid GitHub GraphQL request JSON: %v", err)
}

doc, err := parser.Parse(parser.ParseParams{
Source: req.Query,
})
if err != nil {
return fmt.Errorf("graphql query is unparseable: %v", err)
}

// GitHub GraphQL requests typically have a single operation
var foundOperation *ast.OperationDefinition
for _, def := range doc.Definitions {
if opDef, ok := def.(*ast.OperationDefinition); ok {
foundOperation = opDef
break
}
}

if foundOperation == nil {
return fmt.Errorf("no GraphQL operation found")
}

opType := string(foundOperation.Operation)
opName := ""
if foundOperation.Name != nil {
opName = foundOperation.Name.Value
}

// If operation name is provided in the request, it must match the query
if req.OperationName != "" && opName != req.OperationName {
return fmt.Errorf("operation name mismatch between request and query")
}

// Validate against allowed operations
allowedOps, exists := filter.AllowedOperations[opType]
if !exists {
return fmt.Errorf("GitHub GraphQL operation type '%s' not allowed", opType)
}

if opName == "" {
return fmt.Errorf("GitHub GraphQL operations must be named")
}

for _, allowedOp := range allowedOps {
if allowedOp == opName {
return nil // Operation is allowed
}
}

return fmt.Errorf("GitHub GraphQL %s operation '%s' not allowed", opType, opName)
}

type GraphQLFilter struct {
AllowedOperations map[string][]string `validate:"required"` // map[operationType][]operationName
}
type AllowlistItem struct {
URL string `mapstructure:"url" json:"url"`
Methods HttpMethods `mapstructure:"methods" json:"methods"`
Expand All @@ -187,6 +258,7 @@ type AllowlistItem struct {
LogRequestHeaders bool `mapstructure:"logRequestHeaders" json:"logRequestHeaders"`
LogResponseBody bool `mapstructure:"logResponseBody" json:"logResponseBody"`
LogResponseHeaders bool `mapstructure:"logResponseHeaders" json:"logResponseHeaders"`
GraphQLData *GraphQLFilter `mapstructure:"githubGraphQL" json:"githubGraphQL"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you update the mapstructure and json tags too please?

}

type Allowlist []AllowlistItem
Expand Down Expand Up @@ -420,6 +492,10 @@ func LoadConfig(configFiles []string, deploymentId int) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("failed to parse github base URL: %v", err)
}
gitHubBaseUrlGraphQL, err := url.Parse(strings.Replace(gitHub.BaseURL, "/api/v3", "/api/graphql", 1))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels a bit brittle imo. we've already parsed gitHub.BaseURL into gitHubBaseUrl, so why can't we make a copy and explicitly set foo.Path = "/api/graphql"?

if err != nil {
return nil, fmt.Errorf("failed to parse github GraphQL base URL: %v", err)
}

var headers map[string]string
if gitHub.Token != "" {
Expand Down Expand Up @@ -517,6 +593,23 @@ func LoadConfig(configFiles []string, deploymentId int) (*Config, error) {
Methods: ParseHttpMethods([]string{"GET", "PUT"}),
SetRequestHeaders: headers,
},
// Graphql API with specific operations
AllowlistItem{
URL: gitHubBaseUrlGraphQL.String(),
Methods: ParseHttpMethods([]string{"POST"}),
SetRequestHeaders: headers,
GraphQLData: &GraphQLFilter{
AllowedOperations: map[string][]string{
"query": {
"GetBlameDetails",
},
"mutation": {
"resolveReviewThread",
"unresolveReviewThread",
},
},
},
},
)

if config.Inbound.GitHub.AllowCodeAccess {
Expand Down
163 changes: 163 additions & 0 deletions pkg/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package pkg

import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"

"github.com/mitchellh/mapstructure"
Expand Down Expand Up @@ -135,3 +137,164 @@ func TestHttpMethodsDecodeHook(t *testing.T) {
t.Error(fmt.Errorf("No match: %+v != %+v", output.Methods, expected))
}
}

func TestGraphQLValidation(t *testing.T) {
config := &InboundProxyConfig{}
filter := &GraphQLFilter{
AllowedOperations: map[string][]string{
"query": {"GetRepository", "GetPullRequest"},
"mutation": {"CreateIssue"},
},
}

tests := []struct {
name string
request graphQlRequest
shouldError bool
errorMsg string
}{
{
name: "valid query operation",
request: graphQlRequest{
Query: `query GetRepository {
repository(owner: "owner", name: "name") {
id
}
}`,
OperationName: "GetRepository",
},
shouldError: false,
},
{
name: "valid query operation no operation name",
request: graphQlRequest{
Query: `query GetRepository {
repository(owner: "owner", name: "name") {
id
}
}`,
},
shouldError: false,
},
{
name: "valid mutation operation",
request: graphQlRequest{
Query: `mutation CreateIssue {
createIssue(input: {repositoryId: "123", title: "title"}) {
issue { id }
}
}`,
OperationName: "CreateIssue",
},
shouldError: false,
},
{
name: "operation not in allowlist",
request: graphQlRequest{
Query: `query GetUser {
user(login: "username") {
id
}
}`,
OperationName: "GetUser",
},
shouldError: true,
errorMsg: "GitHub GraphQL query operation 'GetUser' not allowed",
},
{
name: "operation type not allowed",
request: graphQlRequest{
Query: `subscription WatchRepository {
repository {
id
}
}`,
OperationName: "WatchRepository",
},
shouldError: true,
errorMsg: "GitHub GraphQL operation type 'subscription' not allowed",
},
{
name: "unnamed operation",
request: graphQlRequest{
Query: `query {
repository(owner: "owner", name: "name") {
id
}
}`,
},
shouldError: true,
errorMsg: "GitHub GraphQL operations must be named",
},
{
name: "operation name mismatch",
request: graphQlRequest{
Query: `query GetRepository {
repository(owner: "owner", name: "name") {
id
}
}`,
OperationName: "DifferentName",
},
shouldError: true,
errorMsg: "operation name mismatch between request and query",
},
{
name: "invalid GraphQL syntax",
request: graphQlRequest{
Query: `query GetRepository {
repository(owner: "owner", name: "name") {
id
`, // Missing closing brace
OperationName: "GetRepository",
},
shouldError: true,
errorMsg: "graphql query is unparseable",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestBody, err := json.Marshal(tt.request)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}

err = config.validateGraphQLRequest(requestBody, filter)

if tt.shouldError {
if err == nil {
t.Error("expected error but got none")
} else if !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("expected error containing %q but got %q", tt.errorMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}

func TestGraphQLValidationWithNilFilter(t *testing.T) {
config := &InboundProxyConfig{}
request := graphQlRequest{
Query: `query GetRepository {
repository(owner: "owner", name: "name") {
id
}
}`,
OperationName: "GetRepository",
}

requestBody, err := json.Marshal(request)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}

err = config.validateGraphQLRequest(requestBody, nil)
if err != nil {
t.Errorf("expected no error with nil filter but got: %v", err)
}
}
22 changes: 22 additions & 0 deletions pkg/inbound_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ func (config *InboundProxyConfig) Start(tnet *netstack.Net) error {
return
}

// Just to make sure validate all three of these things before checking
if allowlistMatch.GraphQLData != nil &&
c.Request.Method == "POST" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will there ever be graphql PUTs or PATCHes? might be safer to do != "GET" instead


bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
logger.WithError(err).Warn("github_graphql.read_body_error")
c.Header(errorResponseHeader, "1")
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
}
// Restore the body for later use
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

if err := config.validateGraphQLRequest(bodyBytes, allowlistMatch.GraphQLData); err != nil {
logger.WithError(err).Warn("github_graphql.validation_error")
c.Header(errorResponseHeader, "1")
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
}

logger = logger.WithField("allowlist_match", allowlistMatch.URL)

instrumentedTransport, err := BuildInstrumentedRoundTripper(transport, allowlistMatch.URL)
Expand Down
Loading