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

Fix GraphQL patchers 'require' issue #3813

Merged
merged 10 commits into from
Aug 6, 2024
11 changes: 9 additions & 2 deletions .github/workflows/system-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ env:
REGISTRY: ghcr.io
REPO: ghcr.io/datadog/dd-trace-rb
ST_REF: main
FORCE_TESTS:
FORCE_TESTS: -F tests/appsec/waf/test_addresses.py::Test_GraphQL -F tests/appsec/test_blocking_addresses.py::Test_BlockingGraphqlResolvers
FORCE_TESTS_SCENARIO: GRAPHQL_APPSEC

jobs:
build-harness:
Expand Down Expand Up @@ -96,6 +97,7 @@ jobs:
- rails61
- rails70
- rails71
- graphql23
runs-on: ubuntu-latest
name: Build (${{ matrix.app }})
steps:
Expand Down Expand Up @@ -236,6 +238,9 @@ jobs:
- library: ruby
app: rack
scenario: TELEMETRY_METRIC_GENERATION_DISABLED
- library: ruby
app: graphql23
scenario: GRAPHQL_APPSEC
runs-on: ubuntu-latest
needs:
- build-harness
Expand Down Expand Up @@ -268,7 +273,7 @@ jobs:
docker image list
- name: Run scenario
run: |
./run.sh ++docker ${{ matrix.scenario }} ${{ env.FORCE_TESTS }}
./run.sh ++docker ${{ matrix.scenario }} ${{matrix.scenario == env.FORCE_TESTS_SCENARIO && env.FORCE_TESTS || ''}}
env:
DD_API_KEY: ${{ secrets.DD_APPSEC_SYSTEM_TESTS_API_KEY }}
- name: Archive logs
Expand Down Expand Up @@ -296,6 +301,7 @@ jobs:
- rails61
- rails70
- rails71
- graphql23
runs-on: ubuntu-latest
needs:
- test
Expand Down Expand Up @@ -339,6 +345,7 @@ jobs:
- weblog-rails60
- weblog-rails61
- weblog-rails70
- weblog-graphql23
runs-on: ubuntu-latest
needs:
- test
Expand Down
38 changes: 24 additions & 14 deletions lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require 'graphql/language/nodes'
require 'graphql'

require_relative '../../../instrumentation/gateway/argument'

Expand Down Expand Up @@ -32,29 +32,39 @@ def create_arguments_hash
args = {}
@multiplex.queries.each_with_index do |query, index|
resolver_args = {}
resolver_dirs = {}
selections = (query.selected_operation.selections.dup if query.selected_operation) || []
# Iterative tree traversal
while selections.any?
selection = selections.shift
if selection.arguments.any?
selection.arguments.each do |arg|
resolver_args[arg.name] =
if arg.value.is_a?(::GraphQL::Language::Nodes::VariableIdentifier)
query.provided_variables[arg.value.name]
else
arg.value
end
end
set_hash_with_variables(resolver_args, selection.arguments, query.provided_variables)
selection.directives.each do |dir|
resolver_dirs[dir.name] ||= {}
set_hash_with_variables(resolver_dirs[dir.name], dir.arguments, query.provided_variables)
end
selections.concat(selection.selections)
end
unless resolver_args.empty?
args[query.operation_name || "query#{index + 1}"] ||= []
args[query.operation_name || "query#{index + 1}"] << resolver_args
end
next if resolver_args.empty? && resolver_dirs.empty?

args_resolver = (args[query.operation_name || "query#{index + 1}"] ||= [])
# We don't want to add empty hashes so we check again if the arguments and directives are empty
args_resolver << resolver_args unless resolver_args.empty?
args_resolver << resolver_dirs unless resolver_dirs.empty?
end
args
end

# Set the resolver hash (resolver_args and resolver_dirs) with the arguments and provided variables
def set_hash_with_variables(resolver_hash, arguments, provided_variables)
arguments.each do |arg|
resolver_hash[arg.name] =
if arg.value.is_a?(::GraphQL::Language::Nodes::VariableIdentifier)
provided_variables[arg.value.name]
else
arg.value
end
end
end
end
end
end
Expand Down
5 changes: 4 additions & 1 deletion lib/datadog/appsec/contrib/graphql/patcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
require_relative '../patcher'
require_relative 'gateway/watcher'

if Gem.loaded_specs['graphql'] && Gem.loaded_specs['graphql'].version >= Gem::Version.new('2.0.19')
require_relative 'appsec_trace'
end

module Datadog
module AppSec
module Contrib
Expand All @@ -22,7 +26,6 @@ def target_version
end

def patch
require_relative 'appsec_trace'
Gateway::Watcher.watch
::GraphQL::Schema.trace_with(AppSecTrace)
Patcher.instance_variable_set(:@patched, true)
Expand Down
2 changes: 1 addition & 1 deletion lib/datadog/tracing/contrib/graphql/unified_trace.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require 'graphql/tracing'
require 'graphql'

module Datadog
module Tracing
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# frozen_string_literal: true

if Gem.loaded_specs['graphql'] && Gem.loaded_specs['graphql'].version >= Gem::Version.new('2.0.19')
require_relative 'unified_trace'
end

module Datadog
module Tracing
module Contrib
Expand All @@ -9,7 +13,6 @@ module UnifiedTracePatcher
module_function

def patch!(schemas, options)
require_relative 'unified_trace'
if schemas.empty?
::GraphQL::Schema.trace_with(UnifiedTrace, **options)
else
Expand Down
2 changes: 2 additions & 0 deletions sig/datadog/appsec/contrib/graphql/gateway/multiplex.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ module Datadog
private

def create_arguments_hash: () -> Hash[String, Array[Hash[String, String]]]

def set_hash_with_variables: (Hash[String, String] resolver_hash, Array[GraphQL::Language::Nodes::Argument] arguments, Hash[String, String|Integer] provided_variables) -> void
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions spec/datadog/appsec/contrib/graphql/appsec_trace_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
expect(result.to_h['errors']).to eq(
[
{
'message' => "Field 'error' doesn't exist on type 'TestGraphQLQuery'",
'message' => "Field 'error' doesn't exist on type 'Query'",
'locations' => [{ 'line' => 1, 'column' => 13 }],
'path' => ['query test', 'error'],
'extensions' => { 'code' => 'undefinedField', 'typeName' => 'TestGraphQLQuery', 'fieldName' => 'error' }
'extensions' => { 'code' => 'undefinedField', 'typeName' => 'Query', 'fieldName' => 'error' }
}
]
)
Expand Down Expand Up @@ -89,10 +89,10 @@
'errors' =>
[
{
'message' => "Field 'error' doesn't exist on type 'TestGraphQLQuery'",
'message' => "Field 'error' doesn't exist on type 'Query'",
'locations' => [{ 'line' => 1, 'column' => 13 }],
'path' => ['query test', 'error'],
'extensions' => { 'code' => 'undefinedField', 'typeName' => 'TestGraphQLQuery', 'fieldName' => 'error' }
'extensions' => { 'code' => 'undefinedField', 'typeName' => 'Query', 'fieldName' => 'error' }
}
]
}
Expand Down
115 changes: 114 additions & 1 deletion spec/datadog/appsec/contrib/graphql/integration_test_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@
it do
post '/graphql', query: 'mutation { createUser(name: "$testattack") { user { name, id } } }'
expect(JSON.parse(last_response.body)['errors'][0]['title']).to eq('Blocked')
expect(Users.users['$testattack']).to be_nil
expect(TestGraphQL::Users.users['$testattack']).to be_nil
end
end
end
Expand Down Expand Up @@ -459,5 +459,118 @@
end
end
end

describe 'a query with directives' do
subject(:response) { post '/graphql', _json: queries }

context 'with a non-triggering multiplex' do
let(:appsec_ruleset) { blocking_testattack }
let(:queries) do
[
{
'query' => 'query Test($format: String!) { user(id: 1) { name @case(format: $format) } }',
'variables' => { 'format' => 'upcase' }
}
]
end

it do
expect(last_response.body).to eq(
[
{ 'data' => { 'user' => { 'name' => 'BITS' } } },
].to_json
)
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.parse',
),
an_object_having_attributes(
name: 'graphql.execute_multiplex',
),
an_object_having_attributes(
name: 'graphql.execute',
)
)
end

it_behaves_like 'a POST 200 span'
it_behaves_like 'a trace with AppSec tags'
it_behaves_like 'a trace without AppSec events'
end

context 'with a multiplex containing a non-blocking query' do
let(:appsec_ruleset) { nonblocking_testattack }
let(:queries) do
[
{
'query' => 'query Test($format: String!) { user(id: 1) { name @case(format: $format) } }',
'variables' => { 'format' => '$testattack' }
}
]
end

it do
expect(last_response.body).to eq(
[
{ 'data' => { 'user' => { 'name' => 'Bits' } } }
].to_json
)
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.parse',
)
).once
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.execute_multiplex',
)
).once
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.execute',
)
).once
end

it_behaves_like 'a POST 200 span'
it_behaves_like 'a trace with AppSec tags'
it_behaves_like 'a trace with AppSec events'
end

context 'with a multiplex containing a blocking query' do
let(:appsec_ruleset) { blocking_testattack }
let(:queries) do
[
{
'query' => 'query Test($format: String!) { user(id: 1) { name @case(format: $format) } }',
'variables' => { 'format' => '$testattack' }
}
]
end

it do
expect(last_response.body).to eq(
[
{ 'errors' => [{ 'title' => 'Blocked', 'detail' => 'Security provided by Datadog.' }] }
].to_json
)
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.parse',
)
).once
expect(spans).to include(
an_object_having_attributes(
name: 'graphql.execute_multiplex',
)
).once
expect(spans).not_to include(
an_object_having_attributes(
name: 'graphql.execute',
)
)
end
end
end
end
end
33 changes: 33 additions & 0 deletions spec/datadog/tracing/contrib/graphql/loading_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'shellwords'

RSpec.describe 'loading graphql' do
Copy link
Member

Choose a reason for hiding this comment

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

I would think that adding require 'shellwords' in this file is necessary so that it can be executed by itself? The only other place that requires this library is the top-level loading test that I can see.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, I've added it !

context 'then datadog' do
let(:code) do
<<-E
require "graphql"
require "datadog"
exit 0
E
end

it 'loads successfully by itself' do
rv = system("ruby -e #{Shellwords.shellescape(code)}")
expect(rv).to be true
end
end

context 'after datadog' do
let(:code) do
<<-E
require "datadog"
require "graphql"
exit 0
E
end

it 'loads successfully by itself' do
rv = system("ruby -e #{Shellwords.shellescape(code)}")
expect(rv).to be true
end
end
end
17 changes: 9 additions & 8 deletions spec/datadog/tracing/contrib/graphql/support/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@
# TODO: Cleaner way to reset the schema between tests (and most likely clean ::GraphQL::Schema too)
# stub_const is required for GraphqlController, and we cannot use variables defined in let blocks in stub_const
before do
Object.send(:remove_const, :TestGraphQLSchema) if defined?(TestGraphQLSchema)
Object.send(:remove_const, :TestGraphQLQuery) if defined?(TestGraphQLQuery)
Object.send(:remove_const, :TestGraphQLMutationType) if defined?(TestGraphQLMutationType)
Object.send(:remove_const, :Users) if defined?(Users)
Object.send(:remove_const, :TestUserType) if defined?(TestUserType)
TestGraphQL.send(:remove_const, :Case) if defined?(TestGraphQL::Case)
TestGraphQL.send(:remove_const, :Schema) if defined?(TestGraphQL::Schema)
TestGraphQL.send(:remove_const, :Query) if defined?(TestGraphQL::Query)
TestGraphQL.send(:remove_const, :MutationType) if defined?(TestGraphQL::MutationType)
TestGraphQL.send(:remove_const, :Users) if defined?(TestGraphQL::Users)
TestGraphQL.send(:remove_const, :UserType) if defined?(TestGraphQL::UserType)
load 'spec/datadog/tracing/contrib/graphql/support/application_schema.rb'
end
let(:operation) { Datadog::AppSec::Reactive::Operation.new('test') }
let(:schema) { TestGraphQLSchema }
let(:schema) { TestGraphQL::Schema }
end

RSpec.shared_context 'with GraphQL multiplex' do
Expand Down Expand Up @@ -94,9 +95,9 @@ def execute
context: {}
}
end
TestGraphQLSchema.multiplex(queries)
TestGraphQL::Schema.multiplex(queries)
else
TestGraphQLSchema.execute(
TestGraphQL::Schema.execute(
query: params[:query],
operation_name: params[:operationName],
variables: prepare_variables(params[:variables]),
Expand Down
Loading
Loading