Skip to content
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 54 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
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 Oct 30, 2023
3e0651c
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller Oct 30, 2023
ffb2ddf
gci the imports
RomainMuller Oct 30, 2023
2f0d043
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller Oct 31, 2023
9767a09
standardize tag names according to the RFC
RomainMuller Oct 31, 2023
e75d4dd
restore old contrib to previous names to avoid a breaking change per …
RomainMuller Nov 2, 2023
8c89791
more tweaks and fixes
RomainMuller Nov 2, 2023
edb98f7
defer span finish so WAF events get sent properly
RomainMuller Nov 2, 2023
2d44f1d
fix bad query close type
RomainMuller Nov 2, 2023
5ac2b26
repack context data struct to minimize size
RomainMuller Nov 2, 2023
2b47db4
upgrade to latest WAF
RomainMuller Nov 7, 2023
f50921f
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller Nov 14, 2023
bd6e49a
refactor listeners for better encapsulation
RomainMuller Nov 14, 2023
a60a419
document that omiting trivial also omits them for appsec
RomainMuller Nov 15, 2023
d5ce39d
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller Nov 22, 2023
7f37275
internal/appsec: refactor dyngo listeners
RomainMuller Nov 22, 2023
c542113
Merge branch 'romain.marcadier/refactor-dyngo' into romain.marcadier/…
RomainMuller Nov 22, 2023
80e6c3a
add test scenarios
RomainMuller Nov 22, 2023
ff36f02
Merge remote-tracking branch 'origin/main' into romain.marcadier/refa…
RomainMuller Nov 23, 2023
9f29075
use limiter from github.com/DataDog/appsec-internal-go/limiter
RomainMuller Nov 23, 2023
7baf324
use tagged appsec-internal-go
RomainMuller Nov 23, 2023
4a995ce
fix potential integer conversion issue
RomainMuller Nov 23, 2023
670ecd1
restore response headers no cookies feature (lost in merge commit)
RomainMuller Nov 23, 2023
8c2d896
preallocate one more map
RomainMuller Nov 23, 2023
6e25b0f
Merge branch 'main' into romain.marcadier/refactor-dyngo
ddyurchenko Nov 23, 2023
fbc9845
move emitters and listeners under per-protocol trees
RomainMuller Nov 23, 2023
99963f1
further flatten the package tree
RomainMuller Nov 24, 2023
1f74554
use sharedsec.Actions where possible
RomainMuller Nov 24, 2023
6c48e01
stop attempting to use slices
RomainMuller Nov 24, 2023
e98d114
Merge branch 'romain.marcadier/refactor-dyngo' into romain.marcadier/…
RomainMuller Nov 24, 2023
c764deb
un-name unused parameter
RomainMuller Nov 24, 2023
3b30788
add benchmark
RomainMuller Nov 28, 2023
74f41ac
add benchmark against baseline
RomainMuller Dec 1, 2023
627cd46
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller Dec 4, 2023
5427ab5
right size the supported address maps
RomainMuller Dec 4, 2023
5eb94c2
remove WitOverrideSuffix from naming schema (it's not used)
RomainMuller Dec 4, 2023
ed6af8a
centralize tag setting into the GraphQL listener
RomainMuller Dec 4, 2023
334c427
make sure _dd.appsec.enabled is correctly set on the root spans
RomainMuller Dec 4, 2023
0752e8f
somewhat improve handling of allResolvers
RomainMuller Dec 4, 2023
5d571fb
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller Dec 4, 2023
fa4743a
fix the little oopsie
RomainMuller Dec 4, 2023
9c7dbe2
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller Dec 7, 2023
4c8211e
have bench_test run in the gitlab benchmarking
RomainMuller Dec 12, 2023
b4cce00
NoopTagSetter
RomainMuller Dec 13, 2023
1affe2e
use common naming patterns
RomainMuller Dec 13, 2023
b737b29
make parenting relationship a construction concern
RomainMuller Dec 13, 2023
910000c
re-duplicate the error structure
RomainMuller Dec 13, 2023
4e473e3
Merge remote-tracking branch 'origin/main' into romain.marcadier/grap…
RomainMuller Dec 13, 2023
88bd250
stop producing all_resolvers
RomainMuller Dec 13, 2023
ecc8439
hoist wafDaigs up one closure context
RomainMuller Dec 13, 2023
344769b
early feedback from @ahmed-mez
RomainMuller Dec 14, 2023
1f74eba
stay compact 🫥
RomainMuller Dec 14, 2023
b91bc73
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller Dec 18, 2023
e0bb880
Merge branch 'main' into romain.marcadier/graphql/APPSEC-11164
RomainMuller Dec 19, 2023
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
2 changes: 1 addition & 1 deletion .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ variables:
INDEX_FILE: index.txt
KUBERNETES_SERVICE_ACCOUNT_OVERWRITE: dd-trace-go
FF_USE_LEGACY_KUBERNETES_EXECUTION_STRATEGY: "true"
BENCHMARK_TARGETS: "BenchmarkStartRequestSpan|BenchmarkHttpServeTrace|BenchmarkTracerAddSpans|BenchmarkStartSpan|BenchmarkSingleSpanRetention|BenchmarkOTelApiWithCustomTags|BenchmarkInjectW3C|BenchmarkExtractW3C|BenchmarkPartialFlushing"
BENCHMARK_TARGETS: "BenchmarkStartRequestSpan|BenchmarkHttpServeTrace|BenchmarkTracerAddSpans|BenchmarkStartSpan|BenchmarkSingleSpanRetention|BenchmarkOTelApiWithCustomTags|BenchmarkInjectW3C|BenchmarkExtractW3C|BenchmarkPartialFlushing|BenchmarkGraphQL"

include:
- ".gitlab/benchmarks.yml"
Expand Down
387 changes: 387 additions & 0 deletions contrib/99designs/gqlgen/appsec_test.go
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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

amazing test 👏

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",
Copy link
Contributor

Choose a reason for hiding this comment

The 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.
The reason is that the rule format changed a few times so we created constructors instead at some point.

"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"))
}
}
Loading
Loading