diff --git a/lib/datadog/appsec/contrib/graphql/appsec_trace.rb b/lib/datadog/appsec/contrib/graphql/appsec_trace.rb index 4b3eadd6a06..72d74204265 100644 --- a/lib/datadog/appsec/contrib/graphql/appsec_trace.rb +++ b/lib/datadog/appsec/contrib/graphql/appsec_trace.rb @@ -28,20 +28,6 @@ def execute_multiplex(multiplex:) multiplex_return end - - private - - def active_trace - return unless defined?(Datadog::Tracing) - - Datadog::Tracing.active_trace - end - - def active_span - return unless defined?(Datadog::Tracing) - - Datadog::Tracing.active_span - end end end end diff --git a/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb b/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb index db38d14f7b2..56f54a12cc1 100644 --- a/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +++ b/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb @@ -19,7 +19,7 @@ def initialize(multiplex) end def arguments - @arguments ||= create_arguments_hash + @arguments ||= build_arguments_hash end def queries @@ -28,41 +28,77 @@ def queries private - 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 - 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) + # This method builds an array of argument hashes for each field with arguments in the query. + # + # For example, given the following query: + # query ($postSlug: ID = "my-first-post", $withComments: Boolean!) { + # firstPost: post(slug: $postSlug) { + # title + # comments @include(if: $withComments) { + # author { name } + # content + # } + # } + # } + # + # The result would be: + # {"post"=>[{"slug"=>"my-first-post"}], "comments"=>[{"include"=>{"if"=>true}}]} + # + # Note that the `comments` "include" directive is included in the arguments list + def build_arguments_hash + queries.each_with_object({}) do |query, args_hash| + next unless query.selected_operation + + arguments_from_selections(query.selected_operation.selections, query.variables, args_hash) + end + end + + def arguments_from_selections(selections, query_variables, args_hash) + selections.each do |selection| + # rubocop:disable Style/ClassEqualityComparison + next unless selection.class.name == Integration::AST_NODE_CLASS_NAMES[:field] + # rubocop:enable Style/ClassEqualityComparison + + selection_name = selection.alias || selection.name + + if !selection.arguments.empty? || !selection.directives.empty? + args_hash[selection_name] ||= [] + args_hash[selection_name] << + arguments_hash(selection.arguments, query_variables).merge!( + arguments_from_directives(selection.directives, query_variables) + ) 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? + arguments_from_selections(selection.selections, query_variables, args_hash) + end + end + + def arguments_from_directives(directives, query_variables) + directives.each_with_object({}) do |directive, args_hash| + # rubocop:disable Style/ClassEqualityComparison + next unless directive.class.name == Integration::AST_NODE_CLASS_NAMES[:directive] + # rubocop:enable Style/ClassEqualityComparison + + args_hash[directive.name] = arguments_hash(directive.arguments, query_variables) + end + end + + def arguments_hash(arguments, query_variables) + arguments.each_with_object({}) do |argument, args_hash| + args_hash[argument.name] = argument_value(argument, query_variables) 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 + def argument_value(argument, query_variables) + case argument.value.class.name + when Integration::AST_NODE_CLASS_NAMES[:variable_identifier] + # we need to pass query.variables here instead of query.provided_variables, + # since #provided_variables don't know anything about variable default value + query_variables[argument.value.name] + when Integration::AST_NODE_CLASS_NAMES[:input_object] + arguments_hash(argument.value.arguments, query_variables) + else + argument.value end end end diff --git a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb index cc9c4f0275e..1ddf89c1c98 100644 --- a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb @@ -24,27 +24,30 @@ def watch_multiplex(gateway = Instrumentation.gateway) gateway.watch('graphql.multiplex', :appsec) do |stack, gateway_multiplex| block = false event = nil + scope = AppSec::Scope.active_scope - AppSec::Reactive::Operation.new('graphql.multiplex') do |op| - GraphQL::Reactive::Multiplex.subscribe(op, scope.processor_context) do |result| - event = { - waf_result: result, - trace: scope.trace, - span: scope.service_entry_span, - multiplex: gateway_multiplex, - actions: result.actions - } + if scope + AppSec::Reactive::Operation.new('graphql.multiplex') do |op| + GraphQL::Reactive::Multiplex.subscribe(op, scope.processor_context) do |result| + event = { + waf_result: result, + trace: scope.trace, + span: scope.service_entry_span, + multiplex: gateway_multiplex, + actions: result.actions + } + + if scope.service_entry_span + scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') + scope.service_entry_span.set_tag('appsec.event', 'true') + end - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') + scope.processor_context.events << event end - scope.processor_context.events << event + block = GraphQL::Reactive::Multiplex.publish(op, gateway_multiplex) end - - block = GraphQL::Reactive::Multiplex.publish(op, gateway_multiplex) end next [nil, [[:block, event]]] if block diff --git a/lib/datadog/appsec/contrib/graphql/integration.rb b/lib/datadog/appsec/contrib/graphql/integration.rb index 81d003ddc27..827d6df8ccb 100644 --- a/lib/datadog/appsec/contrib/graphql/integration.rb +++ b/lib/datadog/appsec/contrib/graphql/integration.rb @@ -13,6 +13,13 @@ class Integration MINIMUM_VERSION = Gem::Version.new('2.0.19') + AST_NODE_CLASS_NAMES = { + field: 'GraphQL::Language::Nodes::Field', + directive: 'GraphQL::Language::Nodes::Directive', + variable_identifier: 'GraphQL::Language::Nodes::VariableIdentifier', + input_object: 'GraphQL::Language::Nodes::InputObject', + }.freeze + register_as :graphql, auto_patch: false def self.version @@ -24,13 +31,19 @@ def self.loaded? end def self.compatible? - super && version >= MINIMUM_VERSION + super && version >= MINIMUM_VERSION && ast_node_classes_defined? end def self.auto_instrument? true end + def self.ast_node_classes_defined? + AST_NODE_CLASS_NAMES.all? do |_, class_name| + Object.const_defined?(class_name) + end + end + def patcher Patcher end diff --git a/spec/datadog/appsec/contrib/graphql/gateway/multiplex_spec.rb b/spec/datadog/appsec/contrib/graphql/gateway/multiplex_spec.rb index ba685db18ee..2a3ea7bdd5f 100644 --- a/spec/datadog/appsec/contrib/graphql/gateway/multiplex_spec.rb +++ b/spec/datadog/appsec/contrib/graphql/gateway/multiplex_spec.rb @@ -1,31 +1,387 @@ # frozen_string_literal: true -require 'datadog/tracing/contrib/graphql/support/application' - require 'datadog/appsec/spec_helper' require 'datadog/appsec/contrib/graphql/gateway/multiplex' RSpec.describe Datadog::AppSec::Contrib::GraphQL::Gateway::Multiplex do - include_context 'with GraphQL multiplex' + subject(:dd_multiplex) { described_class.new(multiplex) } - let(:gateway) do - described_class.new(multiplex) + let(:schema) do + # we are only testing how arguments are extracted from the queries, + # therefore we don't need a real schema here + stub_const('TestSchema', Class.new(::GraphQL::Schema)) end describe '#arguments' do - it 'returns the arguments of all queries' do - expect(gateway.arguments).to eq({ 'test' => [{ 'id' => 1 }, { 'id' => 10 }], 'query3' => [{ 'name' => 'Caniche' }] }) + let(:multiplex) do + ::GraphQL::Execution::Multiplex.new( + schema: schema, + queries: queries, + context: { dataloader: GraphQL::Dataloader.new(nonblocking: nil) }, + max_complexity: nil + ) + end + + context 'query with argument values provided inline in the query' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY + query { + post(slug: "my-first-post") { + title + content + } + author(username: "john") { name } + } + END_OF_QUERY + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'post' => [{ 'slug' => 'my-first-post' }], + 'author' => [{ 'username' => 'john' }] + ) + ) + end + end + + context 'query with argument values provided in query variables' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query getPost( + $postSlug: String = "default-post", + $authorUsername: String! + ) { + post(slug: $postSlug) { + title + content + } + author(username: $authorUsername) { name } + } + END_OF_QUERY + variables: { 'postSlug' => 'some-post', 'authorUsername' => 'jane' } + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'post' => [{ 'slug' => 'some-post' }], + 'author' => [{ 'username' => 'jane' }] + ) + ) + end + end + + context 'query with arguments with a default value and no value provided' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY + query getPost($postSlug: String = "default-post") { + post(slug: $postSlug) { + title + content + } + } + END_OF_QUERY + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to eq('post' => [{ 'slug' => 'default-post' }]) + end + end + + context 'multiple queries that are querying the same field' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query getPost($postSlug: String) { + post(slug: $postSlug) { + title + content + } + } + END_OF_QUERY + variables: { 'postSlug' => 'some-post' } + ), + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query getPost($postSlug: String) { + post(slug: $postSlug) { + title + content + } + } + END_OF_QUERY + variables: { 'postSlug' => 'another-post' } + ) + ] + end + + it 'returns all arguments for the field' do + expect(dd_multiplex.arguments).to( + eq('post' => [{ 'slug' => 'some-post' }, { 'slug' => 'another-post' }]) + ) + end + end + + context 'query with aliases' do + let(:queries) do + [ + GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query MyTestQuery ($firstPostSlug: String, $secondPostSlug: String) { + firstPost: post(slug: $firstPostSlug) { title } + secondPost: post(slug: $secondPostSlug) { title } + } + END_OF_QUERY + variables: { + 'firstPostSlug' => 'first-post', + 'secondPostSlug' => 'second-post' + } + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'firstPost' => [{ 'slug' => 'first-post' }], + 'secondPost' => [{ 'slug' => 'second-post' }] + ) + ) + end + end + + context 'query with arguments to non-resolver fields' do + let(:queries) do + [ + GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query MyTestQuery ($postSlug: String!, $ignoreDislikes: Boolean!) { + post(slug: $postSlug) { + title + rating(ignoreDislikes: $ignoreDislikes) + } + } + END_OF_QUERY + variables: { + 'postSlug' => 'some-post', + 'ignoreDislikes' => true + } + ) + ] + end + + it 'returns correct arguments including non-resolver field arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'post' => [{ 'slug' => 'some-post' }], + 'rating' => [{ 'ignoreDislikes' => true }] + ) + ) + end + end + + context 'query with directives' do + let(:queries) do + [ + GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + fragment AuthorData on Author { + name + username + } + + query MyTestQuery ( + $postSlug: String!, + $withComments: Boolean!, + $skipAuthor: Boolean! + ) { + post(slug: $postSlug) { + title + content + author @skip(if: $skipAuthor) { + ...AuthorData + } + comments @include(if: $withComments) { + author { ...AuthorData } + content + } + } + } + END_OF_QUERY + variables: { postSlug: 'some-post', withComments: true, skipAuthor: false } + ) + ] + end + + it 'returns correct arguments with directive arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'post' => [{ 'slug' => 'some-post' }], + 'author' => [{ 'skip' => { 'if' => false } }], + 'comments' => [{ 'include' => { 'if' => true } }] + ) + ) + end + end + + # this spec is to ensure that no exceptions are raised when query contains fragments + context 'query with fragments' do + let(:queries) do + [ + GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + fragment AuthorData on Author { + name + username + } + + fragment CommentData on Comment { + author { + ...AuthorData + } + content + } + + query MyTestQuery ($postSlug: String = "my-first-post") { + post(slug: $postSlug) { + title + content + author { + ...AuthorData + } + comments { + ...CommentData + } + } + } + END_OF_QUERY + variables: { 'postSlug' => 'some-post' } + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to eq('post' => [{ 'slug' => 'some-post' }]) + end + end + + context 'mutation' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + mutation addPost( + $postContent: String!, + $authorID: String! + ) { + addPost( + input: { + title: "Some title", + content: $postContent, + authorId: $authorID + } + ) { + post { title slug content } + } + } + END_OF_QUERY + variables: { 'postContent' => 'Some content', 'authorID' => '1' } + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to( + eq( + 'addPost' => [ + { + 'input' => { + 'content' => 'Some content', + 'authorId' => '1', + 'title' => 'Some title' + } + } + ] + ) + ) + end + end + + context 'subscription' do + let(:queries) do + [ + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + subscription postComments($postSlug: String!) { + postCommentsSubscribe(slug: $postSlug) { + comments { + author { name } + content + } + } + } + END_OF_QUERY + variables: { 'postSlug' => 'some-post' } + ) + ] + end + + it 'returns correct arguments' do + expect(dd_multiplex.arguments).to( + eq('postCommentsSubscribe' => [{ 'slug' => 'some-post' }]) + ) + end end end describe '#queries' do - it 'returns the queries that make the multiplex' do - result = [ - 'query test{ user(id: 1) { name } }', - 'query test{ user(id: 10) { name } }', - 'query { userByName(name: "Caniche") { id } }' - ] - expect(gateway.queries.map(&:query_string)).to match_array(result) + let(:multiplex) do + ::GraphQL::Execution::Multiplex.new( + schema: schema, + queries: [query], + context: { dataloader: GraphQL::Dataloader.new(nonblocking: nil) }, + max_complexity: nil + ) + end + + let(:query) do + ::GraphQL::Query.new( + schema, + <<~END_OF_QUERY, + query getPost($postSlug: String!) { + post(slug: $postSlug) { title } + } + END_OF_QUERY + ) + end + + it 'returns queries' do + expect(dd_multiplex.queries).to eq([query]) end end end diff --git a/spec/datadog/appsec/contrib/graphql/integration_spec.rb b/spec/datadog/appsec/contrib/graphql/integration_spec.rb new file mode 100644 index 00000000000..c8093e1aea9 --- /dev/null +++ b/spec/datadog/appsec/contrib/graphql/integration_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' +require 'datadog/appsec/contrib/graphql/integration' + +RSpec.describe Datadog::AppSec::Contrib::GraphQL::Integration do + describe '.ast_node_classes_defined?' do + it 'returns true when all AST node classes are defined' do + expect(described_class.ast_node_classes_defined?).to be(true) + end + + it 'returns false when at least one of AST node classes is not defined' do + hide_const('GraphQL::Language::Nodes::Field') + expect(described_class.ast_node_classes_defined?).to be(false) + end + end +end diff --git a/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb b/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb index bae28385837..812184e6eb8 100644 --- a/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb +++ b/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb @@ -12,7 +12,12 @@ RSpec.describe Datadog::AppSec::Contrib::GraphQL::Reactive::Multiplex do include_context 'with GraphQL multiplex' - let(:expected_arguments) { { 'test' => [{ 'id' => 1 }, { 'id' => 10 }], 'query3' => [{ 'name' => 'Caniche' }] } } + let(:expected_arguments) do + { + 'user' => [{ 'id' => 1 }, { 'id' => 10 }], + 'userByName' => [{ 'name' => 'Caniche' }] + } + end describe '.publish' do it 'propagates multiplex attributes to the operation' do