-
Notifications
You must be signed in to change notification settings - Fork 440
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
appsec/graphql: add support for Threat Monitoring #2309
Merged
Merged
Changes from 50 commits
Commits
Show all changes
54 commits
Select commit
Hold shift + click to select a range
4c374e1
feat(appsec/graphql): add GraphQL in-app WAF integration
RomainMuller 3e0651c
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller ffb2ddf
gci the imports
RomainMuller 2f0d043
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller 9767a09
standardize tag names according to the RFC
RomainMuller e75d4dd
restore old contrib to previous names to avoid a breaking change per …
RomainMuller 8c89791
more tweaks and fixes
RomainMuller edb98f7
defer span finish so WAF events get sent properly
RomainMuller 2d44f1d
fix bad query close type
RomainMuller 5ac2b26
repack context data struct to minimize size
RomainMuller 2b47db4
upgrade to latest WAF
RomainMuller f50921f
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller bd6e49a
refactor listeners for better encapsulation
RomainMuller a60a419
document that omiting trivial also omits them for appsec
RomainMuller d5ce39d
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller 7f37275
internal/appsec: refactor dyngo listeners
RomainMuller c542113
Merge branch 'romain.marcadier/refactor-dyngo' into romain.marcadier/…
RomainMuller 80e6c3a
add test scenarios
RomainMuller ff36f02
Merge remote-tracking branch 'origin/main' into romain.marcadier/refa…
RomainMuller 9f29075
use limiter from github.com/DataDog/appsec-internal-go/limiter
RomainMuller 7baf324
use tagged appsec-internal-go
RomainMuller 4a995ce
fix potential integer conversion issue
RomainMuller 670ecd1
restore response headers no cookies feature (lost in merge commit)
RomainMuller 8c2d896
preallocate one more map
RomainMuller 6e25b0f
Merge branch 'main' into romain.marcadier/refactor-dyngo
ddyurchenko fbc9845
move emitters and listeners under per-protocol trees
RomainMuller 99963f1
further flatten the package tree
RomainMuller 1f74554
use sharedsec.Actions where possible
RomainMuller 6c48e01
stop attempting to use slices
RomainMuller e98d114
Merge branch 'romain.marcadier/refactor-dyngo' into romain.marcadier/…
RomainMuller c764deb
un-name unused parameter
RomainMuller 3b30788
add benchmark
RomainMuller 74f41ac
add benchmark against baseline
RomainMuller 627cd46
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller 5427ab5
right size the supported address maps
RomainMuller 5eb94c2
remove WitOverrideSuffix from naming schema (it's not used)
RomainMuller ed6af8a
centralize tag setting into the GraphQL listener
RomainMuller 334c427
make sure _dd.appsec.enabled is correctly set on the root spans
RomainMuller 0752e8f
somewhat improve handling of allResolvers
RomainMuller 5d571fb
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller fa4743a
fix the little oopsie
RomainMuller 9c7dbe2
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller 4c8211e
have bench_test run in the gitlab benchmarking
RomainMuller b4cce00
NoopTagSetter
RomainMuller 1affe2e
use common naming patterns
RomainMuller b737b29
make parenting relationship a construction concern
RomainMuller 910000c
re-duplicate the error structure
RomainMuller 4e473e3
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller 88bd250
stop producing all_resolvers
RomainMuller ecc8439
hoist wafDaigs up one closure context
RomainMuller 344769b
early feedback from @ahmed-mez
RomainMuller 1f74eba
stay compact 🫥
RomainMuller b91bc73
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller e0bb880
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,387 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2022 Datadog, Inc. | ||
|
||
package gqlgen | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
"path" | ||
"testing" | ||
|
||
"github.com/99designs/gqlgen/client" | ||
"github.com/99designs/gqlgen/graphql" | ||
"github.com/99designs/gqlgen/graphql/handler" | ||
"github.com/99designs/gqlgen/graphql/handler/transport" | ||
"github.com/stretchr/testify/require" | ||
"github.com/vektah/gqlparser/v2" | ||
"github.com/vektah/gqlparser/v2/ast" | ||
"github.com/vektah/gqlparser/v2/gqlerror" | ||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" | ||
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec" | ||
) | ||
|
||
func TestAppSec(t *testing.T) { | ||
restore := enableAppSec(t) | ||
defer restore() | ||
|
||
t.Run("monitoring", func(t *testing.T) { | ||
const ( | ||
topLevelAttack = "he protec" | ||
nestedAttack = "he attac, but most importantly: he Tupac" | ||
) | ||
|
||
schema := gqlparser.MustLoadSchema(&ast.Source{Input: `type Query { | ||
topLevel(id: String!): TopLevel! | ||
topLevelMapped(map: MapInput!, key: String!, index: Int!): TopLevel! | ||
} | ||
|
||
type TopLevel { | ||
nested(id: String!): String! | ||
} | ||
|
||
input MapInput { | ||
ids: [String!]! | ||
bool: Boolean! | ||
float: Float! | ||
}`}) | ||
|
||
server := handler.New(&graphql.ExecutableSchemaMock{ | ||
ExecFunc: execFunc, | ||
SchemaFunc: func() *ast.Schema { return schema }, | ||
}) | ||
server.Use(NewTracer()) | ||
server.AddTransport(transport.POST{}) | ||
c := client.New(server) | ||
|
||
testCases := map[string]struct { | ||
query string | ||
variables map[string]any | ||
events map[string]string | ||
}{ | ||
"basic": { | ||
query: `query TestQuery($topLevelId: String!, $nestedId: String!) { topLevel(id: $topLevelId) { nested(id: $nestedId) } }`, | ||
variables: map[string]any{ | ||
"topLevelId": topLevelAttack, | ||
"nestedId": nestedAttack, | ||
}, | ||
events: map[string]string{ | ||
"test-rule-001": "graphql.resolve(topLevel)", | ||
"test-rule-002": "graphql.resolve(nested)", | ||
}, | ||
}, | ||
"with-default-parameter": { | ||
query: fmt.Sprintf(`query TestQuery($topLevelId: String = %#v, $nestedId: String!) { topLevel(id: $topLevelId) { nested(id: $nestedId) } }`, topLevelAttack), | ||
variables: map[string]any{ | ||
// "topLevelId" omitted (default value used) | ||
"nestedId": nestedAttack, | ||
}, | ||
events: map[string]string{ | ||
"test-rule-001": "graphql.resolve(topLevel)", | ||
"test-rule-002": "graphql.resolve(nested)", | ||
}, | ||
}, | ||
"embedded-variable": { | ||
query: `query TestQuery($topLevelId: String!, $nestedId: String!) { | ||
topLevel: topLevelMapped(map: { ids: ["foo", $topLevelId, "baz"], bool: true, float: 3.14 }, key: "ids", index: 1) { | ||
nested(id: $nestedId) | ||
} | ||
}`, | ||
variables: map[string]any{ | ||
"topLevelId": topLevelAttack, | ||
"nestedId": nestedAttack, | ||
}, | ||
events: map[string]string{ | ||
"test-rule-001": "graphql.resolve(topLevelMapped)", | ||
"test-rule-002": "graphql.resolve(nested)", | ||
}, | ||
}, | ||
} | ||
|
||
for name, tc := range testCases { | ||
t.Run(name, func(t *testing.T) { | ||
mt := mocktracer.Start() | ||
defer mt.Stop() | ||
|
||
var resp map[string]any | ||
err := c.Post( | ||
tc.query, &resp, | ||
client.Var("topLevelId", topLevelAttack), client.Var("nestedId", nestedAttack), | ||
client.Operation("TestQuery"), | ||
) | ||
require.NoError(t, err) | ||
|
||
require.Equal(t, map[string]any{"topLevel": map[string]any{"nested": fmt.Sprintf("%s/%s", topLevelAttack, nestedAttack)}}, resp) | ||
|
||
// Ensure the query produced the expected appsec events | ||
spans := mt.FinishedSpans() | ||
require.NotEmpty(t, spans) | ||
|
||
// The last finished span (which is GraphQL entry) should have the "_dd.appsec.enabled" tag. | ||
require.Equal(t, 1, spans[len(spans)-1].Tag("_dd.appsec.enabled")) | ||
|
||
events := make(map[string]string) | ||
type ddAppsecJSON struct { | ||
Triggers []struct { | ||
Rule struct { | ||
ID string `json:"id"` | ||
} `json:"rule"` | ||
} `json:"triggers"` | ||
} | ||
|
||
// Search for AppSec events in the set of spans | ||
for _, span := range spans { | ||
jsonText, ok := span.Tag("_dd.appsec.json").(string) | ||
if !ok || jsonText == "" { | ||
continue | ||
} | ||
var parsed ddAppsecJSON | ||
err := json.Unmarshal([]byte(jsonText), &parsed) | ||
require.NoError(t, err) | ||
|
||
require.Len(t, parsed.Triggers, 1, "expected exactly 1 trigger on %s span", span.OperationName()) | ||
ruleID := parsed.Triggers[0].Rule.ID | ||
_, duplicate := events[ruleID] | ||
require.False(t, duplicate, "found duplicated hit for rule %s", ruleID) | ||
var origin string | ||
switch name := span.OperationName(); name { | ||
case "graphql.field": | ||
field := span.Tag(tagGraphqlField).(string) | ||
origin = fmt.Sprintf("%s(%s)", "graphql.resolve", field) | ||
case "graphql.query": | ||
origin = "graphql.execute" | ||
default: | ||
require.Fail(t, "rule trigger recorded on unecpected span", "rule %s recorded a hit on unexpected span %s", ruleID, name) | ||
} | ||
events[ruleID] = origin | ||
} | ||
|
||
// Ensure they match the expected outcome | ||
require.Equal(t, tc.events, events) | ||
}) | ||
} | ||
}) | ||
} | ||
|
||
type appSecQuery struct{} | ||
|
||
func (q *appSecQuery) TopLevel(_ context.Context, args struct{ ID string }) (*appSecTopLevel, error) { | ||
return &appSecTopLevel{args.ID}, nil | ||
} | ||
func (q *appSecQuery) TopLevelMapped( | ||
ctx context.Context, | ||
args struct { | ||
Map struct { | ||
IDs []string | ||
Bool bool | ||
Float float64 | ||
} | ||
Key string | ||
Index int32 | ||
}, | ||
) (*appSecTopLevel, error) { | ||
id := args.Map.IDs[args.Index] | ||
return q.TopLevel(ctx, struct{ ID string }{id}) | ||
} | ||
|
||
type appSecTopLevel struct { | ||
id string | ||
} | ||
|
||
func (a *appSecTopLevel) Nested(_ context.Context, args struct{ ID string }) (string, error) { | ||
return fmt.Sprintf("%s/%s", a.id, args.ID), nil | ||
} | ||
|
||
// enableAppSec ensures the environment variable to enable appsec is active, and | ||
// returns a function to restore the previous environment state. | ||
func enableAppSec(t *testing.T) func() { | ||
const rules = `{ | ||
"version": "2.2", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: there's a helper function template somewhere in internal/appsec that you could leverage too. |
||
"metadata": { | ||
"rules_version": "0.1337.42" | ||
}, | ||
"rules": [ | ||
{ | ||
"id": "test-rule-001", | ||
"name": "Phony rule number 1", | ||
"tags": { | ||
"category": "canary", | ||
"type": "meme-protec" | ||
}, | ||
"conditions": [{ | ||
"operator": "phrase_match", | ||
"parameters": { | ||
"inputs": [{ "address": "graphql.server.resolver" }], | ||
"list": ["he protec"] | ||
} | ||
}], | ||
"transformers": ["lowercase"], | ||
"on_match": [] | ||
}, | ||
{ | ||
"id": "test-rule-002", | ||
"name": "Phony rule number 2", | ||
"tags": { | ||
"category": "canary", | ||
"type": "meme-attac" | ||
}, | ||
"conditions": [{ | ||
"operator": "phrase_match", | ||
"parameters": { | ||
"inputs": [{ "address": "graphql.server.resolver" }], | ||
"list": ["he attac"] | ||
} | ||
}], | ||
"transformers": ["lowercase"], | ||
"on_match": [] | ||
}, | ||
{ | ||
"id": "test-rule-003", | ||
"name": "Phony rule number 3", | ||
"tags": { | ||
"category": "canary", | ||
"type": "meme-tupac" | ||
}, | ||
"conditions": [{ | ||
"operator": "phrase_match", | ||
"parameters": { | ||
"inputs": [{ "address": "graphql.server.all_resolvers" }], | ||
"list": ["he tupac"] | ||
} | ||
}], | ||
"transformers": ["lowercase"], | ||
"on_match": [] | ||
} | ||
] | ||
}` | ||
|
||
tmpDir, err := os.MkdirTemp("", "dd-trace-go.graphql-go.graphql.appsec_test.rules-*") | ||
require.NoError(t, err) | ||
rulesFile := path.Join(tmpDir, "rules.json") | ||
err = os.WriteFile(rulesFile, []byte(rules), 0644) | ||
require.NoError(t, err) | ||
|
||
restoreDdAppsecEnabled := setEnv("DD_APPSEC_ENABLED", "1") | ||
restoreDdAppsecRules := setEnv("DD_APPSEC_RULES", rulesFile) | ||
appsec.Start() | ||
|
||
restore := func() { | ||
appsec.Stop() | ||
restoreDdAppsecEnabled() | ||
restoreDdAppsecRules() | ||
_ = os.RemoveAll(tmpDir) | ||
} | ||
|
||
if !appsec.Enabled() { | ||
restore() | ||
t.Skip("could not enable appsec: this platform is likely not supported") | ||
} | ||
|
||
return restore | ||
} | ||
|
||
// setEnv sets an the environment variable named `name` to `value` and returns | ||
// a function that restores the variable to it's original value. | ||
func setEnv(name string, value string) func() { | ||
oldVal := os.Getenv(name) | ||
os.Setenv(name, value) | ||
return func() { | ||
os.Setenv(name, oldVal) | ||
} | ||
} | ||
|
||
func execFunc(ctx context.Context) graphql.ResponseHandler { | ||
type topLevel struct { | ||
id string | ||
} | ||
|
||
op := graphql.GetOperationContext(ctx) | ||
|
||
switch op.Operation.Operation { | ||
case ast.Query: | ||
return func(ctx context.Context) *graphql.Response { | ||
fields := graphql.CollectFields(op, op.Operation.SelectionSet, []string{"Query"}) | ||
var ( | ||
val = make(map[string]any, len(fields)) | ||
errors gqlerror.List | ||
) | ||
for _, field := range fields { | ||
ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ | ||
Object: "Query", | ||
Field: field, | ||
Args: field.ArgumentMap(op.Variables), | ||
}) | ||
fieldVal, err := op.ResolverMiddleware(ctx, func(ctx context.Context) (any, error) { | ||
switch field.Name { | ||
case "topLevel": | ||
arg := field.Arguments.ForName("id") | ||
id, err := arg.Value.Value(op.Variables) | ||
return &topLevel{id.(string)}, err | ||
case "topLevelMapped": | ||
obj, err := field.Arguments.ForName("map").Value.Value(op.Variables) | ||
if err != nil { | ||
return nil, err | ||
} | ||
key, err := field.Arguments.ForName("key").Value.Value(op.Variables) | ||
if err != nil { | ||
return nil, err | ||
} | ||
index, err := field.Arguments.ForName("index").Value.Value(op.Variables) | ||
if err != nil { | ||
return nil, err | ||
} | ||
id := ((obj.(map[string]any))[key.(string)].([]any))[index.(int64)] | ||
return &topLevel{id.(string)}, nil | ||
default: | ||
return nil, fmt.Errorf("unknown field: %s", field.Name) | ||
} | ||
}) | ||
|
||
if err != nil { | ||
errors = append(errors, gqlerror.Errorf("%v", err)) | ||
} else { | ||
redux := make(map[string]any, len(field.SelectionSet)) | ||
for _, nested := range graphql.CollectFields(op, field.SelectionSet, []string{"TopLevel"}) { | ||
ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ | ||
Object: "TopLevel", | ||
Field: nested, | ||
Args: nested.ArgumentMap(op.Variables), | ||
}) | ||
nestedVal, err := op.ResolverMiddleware(ctx, func(ctx context.Context) (any, error) { | ||
switch nested.Name { | ||
case "nested": | ||
arg := nested.Arguments.ForName("id") | ||
id, err := arg.Value.Value(op.Variables) | ||
return fmt.Sprintf("%s/%s", fieldVal.(*topLevel).id, id.(string)), err | ||
default: | ||
return nil, fmt.Errorf("unknown field: %s", nested.Name) | ||
} | ||
}) | ||
if err != nil { | ||
errors = append(errors, gqlerror.Errorf("%v", err)) | ||
} else { | ||
redux[nested.Alias] = nestedVal | ||
} | ||
} | ||
val[field.Alias] = redux | ||
} | ||
} | ||
|
||
data, err := json.Marshal(val) | ||
if err != nil { | ||
errors = append(errors, gqlerror.Errorf("%v", err)) | ||
} | ||
return &graphql.Response{ | ||
Data: data, | ||
Errors: errors, | ||
} | ||
} | ||
|
||
default: | ||
return graphql.OneShot(graphql.ErrorResponse(ctx, "not implemented")) | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
amazing test 👏