Repository: newrelic/newrelic-node-apollo-server-plugin
Pull request #41 (2020-10-06 19:35:12)

Capture query in operation segment attributes
## Proposed Release Notes

- Captures GraphQL query in operation segment attributes as `graphql.operation.query`
- Attempts to obfuscate all argument values in raw captured query string

## Links
Closes: #30 

## Details
- Uses a regex approach to obfuscating graphql query argument values.


Patch:
diff --git a/lib/create-plugin.js b/lib/create-plugin.js
index 77829ac..3622390 100644
--- a/lib/create-plugin.js
+++ b/lib/create-plugin.js
@@ -7,6 +7,7 @@
 
 const ErrorHelper = require('./error-helper.js')
 const errorHelper = new ErrorHelper()
+const cleanQuery = require('./query-utils')
 
 const NOTICED_ERRORS = ErrorHelper.NOTICED_ERRORS
 
@@ -27,6 +28,7 @@ const FIELD_ARGS_ATTR = 'graphql.field.args'
 const OPERATION_TYPE_ATTR = 'graphql.operation.type'
 const OPERATION_NAME_ATTR = 'graphql.operation.name'
 const OPERATION_PATH_ATTR = 'graphql.operation.deepestPath'
+const OPERATION_QUERY_ATTR = 'graphql.operation.query'
 
 const DESTINATIONS = {
   NONE: 0x00
@@ -127,6 +129,14 @@ function createPlugin(instrumentationApi) {
           // default. only used if failed to parse
           let transactionName = '*'
           let segmentName = UNKNOWN_OPERATION
+          let query = responseContext.request.query
+          
+          // attempt to extract arguments to strip from query
+          query = cleanQuery(query)
+          
+          operationSegment.addAttribute(
+            OPERATION_QUERY_ATTR,
+            query)
 
           const operationDetails
             = getOperationDetails(responseContext, deepestResolvedPath.formatted)
diff --git a/lib/query-utils.js b/lib/query-utils.js
new file mode 100644
index 0000000..5f12f2f
--- /dev/null
+++ b/lib/query-utils.js
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const cleanQuery = (query) => {
+  const regex = /\([\s\S]+?[^\)]\)/g
+
+  return query.replace(regex, '(***)')
+}
+
+module.exports = cleanQuery


Test patch:
diff --git a/tests/unit/query-utils.test.js b/tests/unit/query-utils.test.js
new file mode 100644
index 0000000..4832997
--- /dev/null
+++ b/tests/unit/query-utils.test.js
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const tap = require('tap')
+
+const cleanQuery = require('../../lib/query-utils')
+
+tap.test('Obfuscate GraphQL query args tests', (t) => {
+  t.test('Should remove new line and tabs', (t) => {
+    const query = `query {
+      runner
+    }`
+
+    const newQuery = cleanQuery(query)
+
+    t.equal(newQuery, query)
+
+    t.end()
+  })
+
+  t.test('Should hide one arg', (t) => {
+    const query = `query logans(run: 333) {
+      runner
+    }`
+
+    const newQuery = cleanQuery(query)
+
+    t.notOk(newQuery.includes('333'))
+
+    t.end()
+  })
+
+  t.test('Should obfuscate multiple args', (t) => {
+    const query = `query chickens(hens: 'yes', eggs: 0) {
+      eggs(yolk: 'yes')
+    }`
+    
+    const newQuery = cleanQuery(query)
+  
+    t.notOk(newQuery.includes('yes'))
+    t.notOk(newQuery.includes('0'))
+    t.ok('chickens(***)')
+    t.ok('eggs(***)', 'subquery args')
+
+    t.end()
+  })
+
+  t.test('Should not obfuscate aliases', (t) => {
+    const query = `query {
+      pony: horse(horseId: 4, color: 'brown') {
+        horse
+      }
+    }`
+
+    const newQuery = cleanQuery(query)
+
+    t.ok(newQuery.includes('pony: horse'), 'alias is intact')
+    t.ok(newQuery.includes('horse(***)'))
+
+    t.end()
+  })
+
+  t.test('Should obfuscate mutation args', (t) => {
+    const query = `mutation corn(husks: {
+      husky: 'yes',
+      id: 5
+    })`
+
+    const newQuery = cleanQuery(query)
+
+    t.ok(newQuery.includes('corn(***)'))
+  
+    t.end()
+  })
+
+  t.test('Should obfuscate variable placeholders', (t) => {
+    const query = `query ParamQueryWithArgs($arg1: String!, $arg2: String) {
+      paramQuery(blah: $arg1, blee: $arg2)
+    }`
+
+    const newQuery = cleanQuery(query)
+
+    t.ok(newQuery.includes('ParamQueryWithArgs(***)'))
+    t.ok(newQuery.includes('paramQuery(***)'))
+
+    t.end()
+  })
+
+  t.end()
+})
+
diff --git a/tests/versioned/attributes-tests.js b/tests/versioned/attributes-tests.js
index 5cf9de5..a9a159b 100644
--- a/tests/versioned/attributes-tests.js
+++ b/tests/versioned/attributes-tests.js
@@ -363,6 +363,73 @@ function createAttributesTests(t) {
       t.end()
     })
   })
+
+  t.test('should capture query in operation segment attributes', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName = 'Greetings'
+    const query = `query ${expectedName} {
+      ciao
+    }`
+
+    helper.agent.on('transactionFinished', (transaction) => {
+      const operationName = `${OPERATION_PREFIX}/query/${expectedName}`
+
+      const operationSegment = findSegmentByName(transaction.trace.root, operationName)
+
+      const expectedOperationAttributes = {
+        'graphql.operation.query': query
+      }
+
+      const operationAttributes = operationSegment.attributes.get(SEGMENT_DESTINATION)
+
+      t.matches(
+        operationAttributes,
+        expectedOperationAttributes,
+        'should have operation attributes'
+      )
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('query with args should have args obfuscated in raw query attribute', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName = 'ParamQueryWithArgs'
+    const query = `query ${expectedName}($arg1: String!, $arg2: String) {
+      paramQuery(blah: $arg1, blee: $arg2)
+    }`
+
+    const queryJson = {
+      operationName: expectedName,
+      query: query,
+      variables: {
+        arg1: 'first',
+        arg2: 'second'
+      }
+    }
+
+    helper.agent.on('transactionFinished', (transaction) => {
+      const operationName = `${OPERATION_PREFIX}/query/${expectedName}`
+
+      const operationSegment = findSegmentByName(transaction.trace.root, operationName)
+
+      const operationAttributes = operationSegment.attributes.get(SEGMENT_DESTINATION)
+
+      t.ok(operationAttributes['graphql.operation.query']
+        .includes(`${expectedName}(***)`))
+      t.ok(operationAttributes['graphql.operation.query'].includes('paramQuery(***)'))
+    })
+
+    executeJson(serverUrl, queryJson, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
 }
 
 module.exports = {