diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 7c31335db0a..45f88f66cb4 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -14,6 +14,7 @@ require,import-in-the-middle,Apache license 2.0,Copyright 2021 Datadog Inc. require,int64-buffer,MIT,Copyright 2015-2016 Yusuke Kawasaki require,istanbul-lib-coverage,BSD-3-Clause,Copyright 2012-2015 Yahoo! Inc. require,jest-docblock,MIT,Copyright Meta Platforms, Inc. and affiliates. +require,jsonpath-plus,MIT,Copyright (c) 2011-2019 Stefan Goessner, Subbu Allamaraju, Mike Brevoort, Robert Krahn, Brett Zamir, Richard Schneider require,koalas,MIT,Copyright 2013-2017 Brian Woodward require,limiter,MIT,Copyright 2011 John Hurliman require,lodash.sortby,MIT,Copyright JS Foundation and other contributors @@ -26,6 +27,7 @@ require,pprof-format,MIT,Copyright 2022 Stephen Belanger require,protobufjs,BSD-3-Clause,Copyright 2016 Daniel Wirtz require,tlhunter-sorted-set,MIT,Copyright (c) 2023 Datadog Inc. require,retry,MIT,Copyright 2011 Tim Koschützki Felix Geisendörfer +require,rfdc,MIT,Copyright 2019 David Mark Clements require,semver,ISC,Copyright Isaac Z. Schlueter and Contributors require,shell-quote,mit,Copyright (c) 2013 James Halliday dev,@types/node,MIT,Copyright Authors diff --git a/index.d.ts b/index.d.ts index 6d2b495d5da..1d0e2473d01 100644 --- a/index.d.ts +++ b/index.d.ts @@ -729,6 +729,26 @@ declare namespace tracer { * The selection and priority order of context propagation injection and extraction mechanisms. */ propagationStyle?: string[] | PropagationStyle + + /** + * Cloud payload report as tags + */ + cloudPayloadTagging?: { + /** + * Additional JSONPath queries to replace with `redacted` in request payloads + * Undefined or invalid JSONPath queries disable the feature for requests. + */ + request?: string, + /** + * Additional JSONPath queries to replace with `redacted` in response payloads + * Undefined or invalid JSONPath queries disable the feature for responses. + */ + response?: string, + /** + * Maximum depth of payload traversal for tags + */ + maxDepth?: number + } } /** diff --git a/package.json b/package.json index abdaef2e8f9..81b4ce6f53d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "type:doc": "cd docs && yarn && yarn build", "type:test": "cd docs && yarn && yarn test", "lint": "node scripts/check_licenses.js && eslint . && yarn audit --groups dependencies", + "lint-fix": "node scripts/check_licenses.js && eslint . --fix && yarn audit --groups dependencies", "services": "node ./scripts/install_plugin_modules && node packages/dd-trace/test/setup/services", "test": "SERVICES=* yarn services && mocha --expose-gc 'packages/dd-trace/test/setup/node.js' 'packages/*/test/**/*.spec.js'", "test:appsec": "mocha -r \"packages/dd-trace/test/setup/mocha.js\" --exclude \"packages/dd-trace/test/appsec/**/*.plugin.spec.js\" \"packages/dd-trace/test/appsec/**/*.spec.js\"", @@ -87,6 +88,7 @@ "int64-buffer": "^0.1.9", "istanbul-lib-coverage": "3.2.0", "jest-docblock": "^29.7.0", + "jsonpath-plus": "^9.0.0", "koalas": "^1.0.2", "limiter": "1.1.5", "lodash.sortby": "^4.7.0", @@ -98,6 +100,7 @@ "pprof-format": "^2.1.0", "protobufjs": "^7.2.5", "retry": "^0.13.1", + "rfdc": "^1.3.1", "semver": "^7.5.4", "shell-quote": "^1.8.1", "tlhunter-sorted-set": "^0.1.0" @@ -116,11 +119,11 @@ "dotenv": "16.3.1", "esbuild": "0.16.12", "eslint": "^8.57.0", - "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-mocha": "^10.4.3", "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.4.0", + "eslint-config-standard": "^17.1.0", "express": "^4.18.2", "get-port": "^3.2.0", "glob": "^7.1.6", diff --git a/packages/datadog-plugin-aws-sdk/src/base.js b/packages/datadog-plugin-aws-sdk/src/base.js index 7dae7307d13..e815c1e00aa 100644 --- a/packages/datadog-plugin-aws-sdk/src/base.js +++ b/packages/datadog-plugin-aws-sdk/src/base.js @@ -5,9 +5,11 @@ const ClientPlugin = require('../../dd-trace/src/plugins/client') const { storage } = require('../../datadog-core') const { isTrue } = require('../../dd-trace/src/util') const coalesce = require('koalas') +const { tagsFromRequest, tagsFromResponse } = require('../../dd-trace/src/payload-tagging') class BaseAwsSdkPlugin extends ClientPlugin { static get id () { return 'aws' } + static get isPayloadReporter () { return false } get serviceIdentifier () { const id = this.constructor.id.toLowerCase() @@ -20,6 +22,14 @@ class BaseAwsSdkPlugin extends ClientPlugin { return id } + get cloudTaggingConfig () { + return this._tracerConfig.cloudPayloadTagging + } + + get payloadTaggingRules () { + return this.cloudTaggingConfig.rules.aws?.[this.constructor.id] + } + constructor (...args) { super(...args) @@ -51,6 +61,12 @@ class BaseAwsSdkPlugin extends ClientPlugin { this.requestInject(span, request) + if (this.constructor.isPayloadReporter && this.cloudTaggingConfig.requestsEnabled) { + const maxDepth = this.cloudTaggingConfig.maxDepth + const requestTags = tagsFromRequest(this.payloadTaggingRules, request.params, { maxDepth }) + span.addTags(requestTags) + } + const store = storage.getStore() this.enter(span, store) @@ -116,6 +132,7 @@ class BaseAwsSdkPlugin extends ClientPlugin { const params = response.request.params const operation = response.request.operation const extraTags = this.generateTags(params, operation, response) || {} + const tags = Object.assign({ 'aws.response.request_id': response.requestId, 'resource.name': operation, @@ -123,6 +140,22 @@ class BaseAwsSdkPlugin extends ClientPlugin { }, extraTags) span.addTags(tags) + + if (this.constructor.isPayloadReporter && this.cloudTaggingConfig.responsesEnabled) { + const maxDepth = this.cloudTaggingConfig.maxDepth + const responseBody = this.extractResponseBody(response) + const responseTags = tagsFromResponse(this.payloadTaggingRules, responseBody, { maxDepth }) + span.addTags(responseTags) + } + } + + extractResponseBody (response) { + if (response.hasOwnProperty('data')) { + return response.data + } + return Object.fromEntries( + Object.entries(response).filter(([key]) => !['request', 'requestId', 'error', '$metadata'].includes(key)) + ) } generateTags () { diff --git a/packages/datadog-plugin-aws-sdk/src/services/sns.js b/packages/datadog-plugin-aws-sdk/src/services/sns.js index e1cbd38251e..4e2b16f1d18 100644 --- a/packages/datadog-plugin-aws-sdk/src/services/sns.js +++ b/packages/datadog-plugin-aws-sdk/src/services/sns.js @@ -7,6 +7,7 @@ const BaseAwsSdkPlugin = require('../base') class Sns extends BaseAwsSdkPlugin { static get id () { return 'sns' } static get peerServicePrecursors () { return ['topicname'] } + static get isPayloadReporter () { return true } generateTags (params, operation, response) { if (!params) return {} @@ -20,6 +21,7 @@ class Sns extends BaseAwsSdkPlugin { // Get the topic name from the last part of the ARN const topicName = arnParts[arnParts.length - 1] + return { 'resource.name': `${operation} ${params.TopicArn || response.data.TopicArn}`, 'aws.sns.topic_arn': TopicArn, diff --git a/packages/datadog-plugin-aws-sdk/test/sns.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.spec.js index 293833a6009..7b62156f06c 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.spec.js @@ -84,6 +84,242 @@ describe('Sns', function () { }) } + describe('with payload tagging', () => { + before(async () => { + await agent.load('aws-sdk') + await agent.close({ ritmReset: false, wipe: true }) + await agent.load('aws-sdk', {}, { + cloudPayloadTagging: { + request: '$.MessageAttributes.foo,$.MessageAttributes.redacted.StringValue.foo', + response: '$.MessageId,$.Attributes.DisplayName', + maxDepth: 5 + } + }) + }) + + after(() => agent.close({ ritmReset: false, wipe: true })) + + before(done => { + createResources('TestQueue', 'TestTopic', done) + }) + + after(done => { + sns.deleteTopic({ TopicArn }, done) + }) + + after(done => { + sqs.deleteQueue({ QueueUrl }, done) + }) + + it('adds request and response payloads as flattened tags', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal(`publish ${TopicArn}`) + expect(span.meta).to.include({ + 'aws.sns.topic_arn': TopicArn, + topicname: 'TestTopic', + aws_service: 'SNS', + region: 'us-east-1', + 'aws.request.body.TopicArn': TopicArn, + 'aws.request.body.Message': 'message 1', + 'aws.request.body.MessageAttributes.baz.DataType': 'String', + 'aws.request.body.MessageAttributes.baz.StringValue': 'bar', + 'aws.request.body.MessageAttributes.keyOne.DataType': 'String', + 'aws.request.body.MessageAttributes.keyOne.StringValue': 'keyOne', + 'aws.request.body.MessageAttributes.keyTwo.DataType': 'String', + 'aws.request.body.MessageAttributes.keyTwo.StringValue': 'keyTwo', + 'aws.response.body.MessageId': 'redacted' + }) + }).then(done, done) + + sns.publish({ + TopicArn, + Message: 'message 1', + MessageAttributes: { + baz: { DataType: 'String', StringValue: 'bar' }, + keyOne: { DataType: 'String', StringValue: 'keyOne' }, + keyTwo: { DataType: 'String', StringValue: 'keyTwo' } + } + }, e => e && done(e)) + }) + + it('expands and redacts keys identified as expandable', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal(`publish ${TopicArn}`) + expect(span.meta).to.include({ + 'aws.sns.topic_arn': TopicArn, + topicname: 'TestTopic', + aws_service: 'SNS', + region: 'us-east-1', + 'aws.request.body.TopicArn': TopicArn, + 'aws.request.body.Message': 'message 1', + 'aws.request.body.MessageAttributes.redacted.StringValue.foo': 'redacted', + 'aws.request.body.MessageAttributes.unredacted.StringValue.foo': 'bar', + 'aws.request.body.MessageAttributes.unredacted.StringValue.baz': 'yup', + 'aws.response.body.MessageId': 'redacted' + }) + }).then(done, done) + + sns.publish({ + TopicArn, + Message: 'message 1', + MessageAttributes: { + unredacted: { DataType: 'String', StringValue: '{"foo": "bar", "baz": "yup"}' }, + redacted: { DataType: 'String', StringValue: '{"foo": "bar"}' } + } + }, e => e && done(e)) + }) + + describe('user-defined redaction', () => { + it('redacts user-defined keys to suppress in request', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal(`publish ${TopicArn}`) + expect(span.meta).to.include({ + 'aws.sns.topic_arn': TopicArn, + topicname: 'TestTopic', + aws_service: 'SNS', + region: 'us-east-1', + 'aws.request.body.TopicArn': TopicArn, + 'aws.request.body.Message': 'message 1', + 'aws.request.body.MessageAttributes.foo': 'redacted', + 'aws.request.body.MessageAttributes.keyOne.DataType': 'String', + 'aws.request.body.MessageAttributes.keyOne.StringValue': 'keyOne', + 'aws.request.body.MessageAttributes.keyTwo.DataType': 'String', + 'aws.request.body.MessageAttributes.keyTwo.StringValue': 'keyTwo' + }) + expect(span.meta).to.have.property('aws.response.body.MessageId') + }).then(done, done) + + sns.publish({ + TopicArn, + Message: 'message 1', + MessageAttributes: { + foo: { DataType: 'String', StringValue: 'bar' }, + keyOne: { DataType: 'String', StringValue: 'keyOne' }, + keyTwo: { DataType: 'String', StringValue: 'keyTwo' } + } + }, e => e && done(e)) + }) + + // TODO add response tests + it('redacts user-defined keys to suppress in response', done => { + agent.use(traces => { + const span = traces[0][0] + expect(span.resource).to.equal(`getTopicAttributes ${TopicArn}`) + expect(span.meta).to.include({ + 'aws.sns.topic_arn': TopicArn, + topicname: 'TestTopic', + aws_service: 'SNS', + region: 'us-east-1', + 'aws.request.body.TopicArn': TopicArn, + 'aws.response.body.Attributes.DisplayName': 'redacted' + }) + }).then(done, done) + + sns.getTopicAttributes({ TopicArn }, e => e && done(e)) + }) + }) + + describe('redaction of internally suppressed keys', () => { + const supportsSMSNotification = (moduleName, version) => { + switch (moduleName) { + case 'aws-sdk': + // aws-sdk-js phone notifications introduced in c6d1bb1a + return semver.intersects(version, '>=2.10.0') + case '@aws-sdk/smithy-client': + return true + default: + return false + } + } + + if (supportsSMSNotification(moduleName, version)) { + // TODO + describe.skip('phone number', () => { + before(done => { + sns.createSMSSandboxPhoneNumber({ PhoneNumber: '+33628606135' }, err => err && done(err)) + sns.createSMSSandboxPhoneNumber({ PhoneNumber: '+33628606136' }, err => err && done(err)) + }) + + after(done => { + sns.deleteSMSSandboxPhoneNumber({ PhoneNumber: '+33628606135' }, err => err && done(err)) + sns.deleteSMSSandboxPhoneNumber({ PhoneNumber: '+33628606136' }, err => err && done(err)) + }) + + it('redacts phone numbers in request', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal('publish') + expect(span.meta).to.include({ + aws_service: 'SNS', + region: 'us-east-1', + 'aws.request.body.PhoneNumber': 'redacted', + 'aws.request.body.Message': 'message 1' + }) + }).then(done, done) + + sns.publish({ + PhoneNumber: '+33628606135', + Message: 'message 1' + }, e => e && done(e)) + }) + + it('redacts phone numbers in response', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal('publish') + expect(span.meta).to.include({ + aws_service: 'SNS', + region: 'us-east-1', + 'aws.response.body.PhoneNumber': 'redacted' + }) + }).then(done, done) + + sns.listSMSSandboxPhoneNumbers({ + PhoneNumber: '+33628606135', + Message: 'message 1' + }, e => e && done(e)) + }) + }) + } + + describe('subscription confirmation tokens', () => { + it('redacts tokens in request', done => { + agent.use(traces => { + const span = traces[0][0] + + expect(span.resource).to.equal(`confirmSubscription ${TopicArn}`) + expect(span.meta).to.include({ + aws_service: 'SNS', + 'aws.sns.topic_arn': TopicArn, + topicname: 'TestTopic', + region: 'us-east-1', + 'aws.request.body.Token': 'redacted', + 'aws.request.body.TopicArn': TopicArn + }) + }).then(done, done) + + sns.confirmSubscription({ + TopicArn, + Token: '1234' + }, () => {}) + }) + + // TODO + it.skip('redacts tokens in response', () => { + + }) + }) + }) + }) + describe('no configuration', () => { before(() => { parentId = '0' @@ -284,7 +520,7 @@ describe('Sns', function () { }) after(() => { - return agent.close({ ritmReset: false }) + return agent.close({ ritmReset: false, wipe: true }) }) afterEach(() => { diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index 31592accab2..625a9ce19fa 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -18,6 +18,7 @@ const { updateConfig } = require('./telemetry') const telemetryMetrics = require('./telemetry/metrics') const { getIsGCPFunction, getIsAzureFunction } = require('./serverless') const { ORIGIN_KEY } = require('./constants') +const { appendRules } = require('./payload-tagging/config') const tracerMetrics = telemetryMetrics.manager.namespace('tracers') @@ -173,6 +174,21 @@ function validateNamingVersion (versionString) { return versionString } +/** + * Given a string of comma-separated paths, return the array of paths. + * If a blank path is provided a null is returned to signal that the feature is disabled. + * An empty array means the feature is enabled but that no rules need to be applied. + * + * @param {string} input + * @returns {[string]|null} + */ +function splitJSONPathRules (input) { + if (!input) return null + if (Array.isArray(input)) return input + if (input === 'all') return [] + return input.split(',') +} + // Shallow clone with property name remapping function remapify (input, mappings) { if (!input) return @@ -281,6 +297,26 @@ class Config { null ) + const DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING = splitJSONPathRules( + coalesce( + process.env.DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING, + options.cloudPayloadTagging?.request, + '' + )) + + const DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING = splitJSONPathRules( + coalesce( + process.env.DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING, + options.cloudPayloadTagging?.response, + '' + )) + + const DD_TRACE_CLOUD_PAYLOAD_TAGGING_MAX_DEPTH = coalesce( + process.env.DD_TRACE_CLOUD_PAYLOAD_TAGGING_MAX_DEPTH, + options.cloudPayloadTagging?.maxDepth, + 10 + ) + // TODO: refactor this.apiKey = DD_API_KEY @@ -291,6 +327,15 @@ class Config { type: DD_INSTRUMENTATION_INSTALL_TYPE } + this.cloudPayloadTagging = { + requestsEnabled: !!DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING, + responsesEnabled: !!DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING, + maxDepth: DD_TRACE_CLOUD_PAYLOAD_TAGGING_MAX_DEPTH, + rules: appendRules( + DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING, DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING + ) + } + this._applyDefaults() this._applyEnvironment() this._applyOptions(options) diff --git a/packages/dd-trace/src/constants.js b/packages/dd-trace/src/constants.js index 0d9bcc495dd..4c38b7916e5 100644 --- a/packages/dd-trace/src/constants.js +++ b/packages/dd-trace/src/constants.js @@ -34,5 +34,8 @@ module.exports = { SCI_REPOSITORY_URL: '_dd.git.repository_url', SCI_COMMIT_SHA: '_dd.git.commit.sha', APM_TRACING_ENABLED_KEY: '_dd.apm.enabled', - APPSEC_PROPAGATION_KEY: '_dd.p.appsec' + APPSEC_PROPAGATION_KEY: '_dd.p.appsec', + PAYLOAD_TAG_REQUEST_PREFIX: 'aws.request.body', + PAYLOAD_TAG_RESPONSE_PREFIX: 'aws.response.body', + PAYLOAD_TAGGING_MAX_TAGS: 758 } diff --git a/packages/dd-trace/src/payload-tagging/config/aws.json b/packages/dd-trace/src/payload-tagging/config/aws.json new file mode 100644 index 00000000000..400b25bf670 --- /dev/null +++ b/packages/dd-trace/src/payload-tagging/config/aws.json @@ -0,0 +1,30 @@ +{ + "sns": { + "request": [ + "$.Attributes.KmsMasterKeyId", + "$.Attributes.PlatformCredential", + "$.Attributes.PlatformPrincipal", + "$.Attributes.Token", + "$.AWSAccountId", + "$.Endpoint", + "$.OneTimePassword", + "$.phoneNumber", + "$.PhoneNumber", + "$.Token" + ], + "response": [ + "$.Attributes.KmsMasterKeyId", + "$.Attributes.Token", + "$.Endpoints.*.Token", + "$.PhoneNumber", + "$.PhoneNumbers", + "$.phoneNumbers", + "$.PlatformApplication.*.PlatformCredential", + "$.PlatformApplication.*.PlatformPrincipal", + "$.Subscriptions.*.Endpoint" + ], + "expand": [ + "$.MessageAttributes.*.StringValue" + ] + } +} diff --git a/packages/dd-trace/src/payload-tagging/config/index.js b/packages/dd-trace/src/payload-tagging/config/index.js new file mode 100644 index 00000000000..16ab4dfd814 --- /dev/null +++ b/packages/dd-trace/src/payload-tagging/config/index.js @@ -0,0 +1,30 @@ +const aws = require('./aws.json') +const sdks = { aws } + +function getSDKRules (sdk, requestInput, responseInput) { + return Object.fromEntries( + Object.entries(sdk).map(([service, serviceRules]) => { + return [ + service, + { + request: serviceRules.request.concat(requestInput || []), + response: serviceRules.response.concat(responseInput || []), + expand: serviceRules.expand || [] + } + ] + }) + ) +} + +function appendRules (requestInput, responseInput) { + return Object.fromEntries( + Object.entries(sdks).map(([name, sdk]) => { + return [ + name, + getSDKRules(sdk, requestInput, responseInput) + ] + }) + ) +} + +module.exports = { appendRules } diff --git a/packages/dd-trace/src/payload-tagging/index.js b/packages/dd-trace/src/payload-tagging/index.js new file mode 100644 index 00000000000..c7f5dd19d30 --- /dev/null +++ b/packages/dd-trace/src/payload-tagging/index.js @@ -0,0 +1,93 @@ +const rfdc = require('rfdc')({ proto: false, circles: false }) + +const { + PAYLOAD_TAG_REQUEST_PREFIX, + PAYLOAD_TAG_RESPONSE_PREFIX +} = require('../constants') + +const jsonpath = require('jsonpath-plus').JSONPath + +const { tagsFromObject } = require('./tagging') + +/** + * Given an identified value, attempt to parse it as JSON if relevant + * + * @param {any} value + * @returns {any} the parsed object if parsing was successful, the input if not + */ +function maybeJSONParseValue (value) { + if (typeof value !== 'string' || value[0] !== '{') { + return value + } + + try { + return JSON.parse(value) + } catch (e) { + return value + } +} + +/** + * Apply expansion to all expansion JSONPath queries + * + * @param {Object} object + * @param {[String]} expansionRules list of JSONPath queries + */ +function expand (object, expansionRules) { + for (const rule of expansionRules) { + jsonpath(rule, object, (value, _type, desc) => { + desc.parent[desc.parentProperty] = maybeJSONParseValue(value) + }) + } +} + +/** + * Apply redaction to all redaction JSONPath queries + * + * @param {Object} object + * @param {[String]} redactionRules + */ +function redact (object, redactionRules) { + for (const rule of redactionRules) { + jsonpath(rule, object, (_value, _type, desc) => { + desc.parent[desc.parentProperty] = 'redacted' + }) + } +} + +/** + * Generate a map of tag names to tag values by performing: + * 1. Attempting to parse identified fields as JSON + * 2. Redacting fields identified by redaction rules + * 3. Flattening the resulting object, producing as many tag name/tag value pairs + * as there are leaf values in the object + * This function performs side-effects on a _copy_ of the input object. + * + * @param {Object} config sdk configuration for the service + * @param {[String]} config.expand expansion rules for the service + * @param {[String]} config.request redaction rules for the request + * @param {[String]} config.response redaction rules for the response + * @param {Object} object the input object to generate tags from + * @param {Object} opts tag generation options + * @param {String} opts.prefix prefix for all generated tags + * @param {number} opts.maxDepth maximum depth to traverse the object + * @returns + */ +function computeTags (config, object, opts) { + const payload = rfdc(object) + const redactionRules = opts.prefix === PAYLOAD_TAG_REQUEST_PREFIX ? config.request : config.response + const expansionRules = config.expand + expand(payload, expansionRules) + redact(payload, redactionRules) + return tagsFromObject(payload, opts) +} + +function tagsFromRequest (config, object, opts) { + return computeTags(config, object, { ...opts, prefix: PAYLOAD_TAG_REQUEST_PREFIX }) +} + +function tagsFromResponse (config, object, opts) { + return computeTags(config, object, { ...opts, prefix: PAYLOAD_TAG_RESPONSE_PREFIX }) +} + +module.exports = { computeTags, tagsFromRequest, tagsFromResponse } diff --git a/packages/dd-trace/src/payload-tagging/tagging.js b/packages/dd-trace/src/payload-tagging/tagging.js new file mode 100644 index 00000000000..4643b5d7a40 --- /dev/null +++ b/packages/dd-trace/src/payload-tagging/tagging.js @@ -0,0 +1,83 @@ +const { PAYLOAD_TAGGING_MAX_TAGS } = require('../constants') + +const redactedKeys = [ + 'authorization', 'x-authorization', 'password', 'token' +] +const truncated = 'truncated' +const redacted = 'redacted' + +function escapeKey (key) { + return key.replaceAll('.', '\\.') +} + +/** + * Compute normalized payload tags from any given object. + * + * @param {object} object + * @param {import('./mask').Mask} mask + * @param {number} maxDepth + * @param {string} prefix + * @returns + */ +function tagsFromObject (object, opts) { + const { maxDepth, prefix } = opts + + let tagCount = 0 + let abort = false + const result = {} + + function tagRec (prefix, object, depth = 0) { + // Off by one: _dd.payload_tags_trimmed counts as 1 tag + if (abort) { return } + + if (tagCount >= PAYLOAD_TAGGING_MAX_TAGS - 1) { + abort = true + result['_dd.payload_tags_incomplete'] = true + return + } + + if (depth >= maxDepth && typeof object === 'object') { + tagCount += 1 + result[prefix] = truncated + return + } + + if (object === undefined) { + tagCount += 1 + result[prefix] = 'undefined' + return + } + + if (object === null) { + tagCount += 1 + result[prefix] = 'null' + return + } + + if (['number', 'boolean'].includes(typeof object) || Buffer.isBuffer(object)) { + tagCount += 1 + result[prefix] = object.toString().substring(0, 5000) + return + } + + if (typeof object === 'string') { + tagCount += 1 + result[prefix] = object.substring(0, 5000) + } + + if (typeof object === 'object') { + for (const [key, value] of Object.entries(object)) { + if (redactedKeys.includes(key.toLowerCase())) { + tagCount += 1 + result[`${prefix}.${escapeKey(key)}`] = redacted + } else { + tagRec(`${prefix}.${escapeKey(key)}`, value, depth + 1) + } + } + } + } + tagRec(prefix, object) + return result +} + +module.exports = { tagsFromObject } diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index 321f7d63534..1150a98c257 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -2007,4 +2007,83 @@ describe('Config', () => { } })).to.have.nested.property('appsec.apiSecurity.requestSampling', 0.1) }) + + context('payload tagging', () => { + let env + + const staticConfig = require('../src/payload-tagging/config/aws') + + beforeEach(() => { + env = process.env + }) + + afterEach(() => { + process.env = env + }) + + it('defaults', () => { + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', false) + expect(taggingConfig).to.have.property('responsesEnabled', false) + expect(taggingConfig).to.have.property('maxDepth', 10) + }) + + it('enabling requests with no additional filter', () => { + process.env.DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING = 'all' + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', true) + expect(taggingConfig).to.have.property('responsesEnabled', false) + expect(taggingConfig).to.have.property('maxDepth', 10) + const awsRules = taggingConfig.rules.aws + for (const [serviceName, service] of Object.entries(awsRules)) { + expect(service.request).to.deep.equal(staticConfig[serviceName].request) + } + }) + + it('enabling requests with an additional filter', () => { + process.env.DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING = '$.foo.bar' + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', true) + expect(taggingConfig).to.have.property('responsesEnabled', false) + expect(taggingConfig).to.have.property('maxDepth', 10) + const awsRules = taggingConfig.rules.aws + for (const [, service] of Object.entries(awsRules)) { + expect(service.request).to.include('$.foo.bar') + } + }) + + it('enabling responses with no additional filter', () => { + process.env.DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING = 'all' + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', false) + expect(taggingConfig).to.have.property('responsesEnabled', true) + expect(taggingConfig).to.have.property('maxDepth', 10) + const awsRules = taggingConfig.rules.aws + for (const [serviceName, service] of Object.entries(awsRules)) { + expect(service.response).to.deep.equal(staticConfig[serviceName].response) + } + }) + + it('enabling responses with an additional filter', () => { + process.env.DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING = '$.foo.bar' + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', false) + expect(taggingConfig).to.have.property('responsesEnabled', true) + expect(taggingConfig).to.have.property('maxDepth', 10) + const awsRules = taggingConfig.rules.aws + for (const [, service] of Object.entries(awsRules)) { + expect(service.response).to.include('$.foo.bar') + } + }) + + it('overriding max depth', () => { + process.env.DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING = 'all' + process.env.DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING = 'all' + process.env.DD_TRACE_CLOUD_PAYLOAD_TAGGING_MAX_DEPTH = 7 + const taggingConfig = new Config().cloudPayloadTagging + expect(taggingConfig).to.have.property('requestsEnabled', true) + expect(taggingConfig).to.have.property('responsesEnabled', true) + expect(taggingConfig).to.have.property('maxDepth', 7) + }) + }) }) diff --git a/packages/dd-trace/test/payload-tagging/index.spec.js b/packages/dd-trace/test/payload-tagging/index.spec.js new file mode 100644 index 00000000000..a4f4da8108e --- /dev/null +++ b/packages/dd-trace/test/payload-tagging/index.spec.js @@ -0,0 +1,220 @@ +const { + PAYLOAD_TAG_REQUEST_PREFIX, + PAYLOAD_TAG_RESPONSE_PREFIX +} = require('../../src/constants') +const { tagsFromObject } = require('../../src/payload-tagging/tagging') +const { computeTags } = require('../../src/payload-tagging') + +const { expect } = require('chai') + +const defaultOpts = { maxDepth: 10, prefix: 'http.payload' } + +describe('Payload tagger', () => { + describe('tag count cutoff', () => { + it('should generate many tags when not reaching the cap', () => { + const belowCap = 200 + const input = { foo: Object.fromEntries([...Array(belowCap).keys()].map(i => [i, i])) } + const tagCount = Object.entries(tagsFromObject(input, defaultOpts)).length + expect(tagCount).to.equal(belowCap) + }) + + it('should stop generating tags once the cap is reached', () => { + const aboveCap = 759 + const input = { foo: Object.fromEntries([...Array(aboveCap).keys()].map(i => [i, i])) } + const tagCount = Object.entries(tagsFromObject(input, defaultOpts)).length + expect(tagCount).to.not.equal(aboveCap) + expect(tagCount).to.equal(758) + }) + }) + + describe('best-effort redacting of keys', () => { + it('should redact disallowed keys', () => { + const input = { + foo: { + bar: { + token: 'tokenpleaseredact', + authorization: 'pleaseredact', + valid: 'valid' + }, + baz: { + password: 'shouldgo', + 'x-authorization': 'shouldbegone', + data: 'shouldstay' + } + } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.token': 'redacted', + 'http.payload.foo.bar.authorization': 'redacted', + 'http.payload.foo.bar.valid': 'valid', + 'http.payload.foo.baz.password': 'redacted', + 'http.payload.foo.baz.x-authorization': 'redacted', + 'http.payload.foo.baz.data': 'shouldstay' + }) + }) + + it('should redact banned keys even if they are objects', () => { + const input = { + foo: { + authorization: { + token: 'tokenpleaseredact', + authorization: 'pleaseredact', + valid: 'valid' + }, + baz: { + password: 'shouldgo', + 'x-authorization': 'shouldbegone', + data: 'shouldstay' + } + } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.authorization': 'redacted', + 'http.payload.foo.baz.password': 'redacted', + 'http.payload.foo.baz.x-authorization': 'redacted', + 'http.payload.foo.baz.data': 'shouldstay' + }) + }) + }) + + describe('escaping', () => { + it('should escape `.` characters in individual keys', () => { + const input = { 'foo.bar': { baz: 'quux' } } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo\\.bar.baz': 'quux' + }) + }) + }) + + describe('parsing', () => { + it('should transform null values to "null" string', () => { + const input = { foo: 'bar', baz: null } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'bar', + 'http.payload.baz': 'null' + }) + }) + + it('should transform undefined values to "undefined" string', () => { + const input = { foo: 'bar', baz: undefined } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'bar', + 'http.payload.baz': 'undefined' + }) + }) + + it('should transform boolean values to strings', () => { + const input = { foo: true, bar: false } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'true', + 'http.payload.bar': 'false' + }) + }) + + it('should decode buffers as UTF-8', () => { + const input = { foo: Buffer.from('bar') } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.foo': 'bar' }) + }) + + it('should provide tags from simple JSON objects, casting to strings where necessary', () => { + const input = { + foo: { bar: { baz: 1, quux: 2 } }, + asimplestring: 'isastring', + anullvalue: null, + anundefined: undefined + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.baz': '1', + 'http.payload.foo.bar.quux': '2', + 'http.payload.asimplestring': 'isastring', + 'http.payload.anullvalue': 'null', + 'http.payload.anundefined': 'undefined' + }) + }) + + it('should index tags when encountering arrays', () => { + const input = { foo: { bar: { list: ['v0', 'v1', 'v2'] } } } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.list.0': 'v0', + 'http.payload.foo.bar.list.1': 'v1', + 'http.payload.foo.bar.list.2': 'v2' + }) + }) + + it('should not replace a real value at max depth', () => { + const input = { + 1: { 2: { 3: { 4: { 5: { 6: { 7: { 8: { 9: { 10: 11 } } } } } } } } } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.1.2.3.4.5.6.7.8.9.10': '11' }) + }) + + it('should truncate paths beyond max depth', () => { + const input = { + 1: { 2: { 3: { 4: { 5: { 6: { 7: { 8: { 9: { 10: { 11: 'too much' } } } } } } } } } } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.1.2.3.4.5.6.7.8.9.10': 'truncated' }) + }) + }) +}) + +describe('Tagging orchestration', () => { + it('should use the request config when given the request prefix', () => { + const config = { + request: ['$.request'], + response: ['$.response'], + expand: [] + } + const input = { + request: 'foo', + response: 'bar' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: PAYLOAD_TAG_REQUEST_PREFIX }) + expect(tags).to.have.property(`${PAYLOAD_TAG_REQUEST_PREFIX}.request`, 'redacted') + expect(tags).to.have.property(`${PAYLOAD_TAG_REQUEST_PREFIX}.response`, 'bar') + }) + + it('should use the response config when given the response prefix', () => { + const config = { + request: ['$.request'], + response: ['$.response'], + expand: [] + } + const input = { + request: 'foo', + response: 'bar' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: PAYLOAD_TAG_RESPONSE_PREFIX }) + expect(tags).to.have.property(`${PAYLOAD_TAG_RESPONSE_PREFIX}.response`, 'redacted') + expect(tags).to.have.property(`${PAYLOAD_TAG_RESPONSE_PREFIX}.request`, 'foo') + }) + + it('should apply expansion rules', () => { + const config = { + request: [], + response: [], + expand: ['$.request', '$.response', '$.invalid'] + } + const input = { + request: '{ "foo": "bar" }', + response: '{ "baz": "quux" }', + invalid: '{ invalid JSON }', + untargeted: '{ "foo": "bar" }' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: 'foo' }) + expect(tags).to.have.property('foo.request.foo', 'bar') + expect(tags).to.have.property('foo.response.baz', 'quux') + expect(tags).to.have.property('foo.invalid', '{ invalid JSON }') + expect(tags).to.have.property('foo.untargeted', '{ "foo": "bar" }') + }) +}) diff --git a/packages/dd-trace/test/payload_tagging.spec.js b/packages/dd-trace/test/payload_tagging.spec.js new file mode 100644 index 00000000000..630c773d567 --- /dev/null +++ b/packages/dd-trace/test/payload_tagging.spec.js @@ -0,0 +1,222 @@ +require('./setup/tap') + +const { + PAYLOAD_TAG_REQUEST_PREFIX, + PAYLOAD_TAG_RESPONSE_PREFIX +} = require('../src/constants') +const { tagsFromObject } = require('../src/payload-tagging/tagging') +const { computeTags } = require('../src/payload-tagging') + +const { expect } = require('chai') + +const defaultOpts = { maxDepth: 10, prefix: 'http.payload' } + +describe('Payload tagger', () => { + describe('tag count cutoff', () => { + it('should generate many tags when not reaching the cap', () => { + const belowCap = 200 + const input = { foo: Object.fromEntries([...Array(belowCap).keys()].map(i => [i, i])) } + const tagCount = Object.entries(tagsFromObject(input, defaultOpts)).length + expect(tagCount).to.equal(belowCap) + }) + + it('should stop generating tags once the cap is reached', () => { + const aboveCap = 759 + const input = { foo: Object.fromEntries([...Array(aboveCap).keys()].map(i => [i, i])) } + const tagCount = Object.entries(tagsFromObject(input, defaultOpts)).length + expect(tagCount).to.not.equal(aboveCap) + expect(tagCount).to.equal(758) + }) + }) + + describe('best-effort redacting of keys', () => { + it('should redact disallowed keys', () => { + const input = { + foo: { + bar: { + token: 'tokenpleaseredact', + authorization: 'pleaseredact', + valid: 'valid' + }, + baz: { + password: 'shouldgo', + 'x-authorization': 'shouldbegone', + data: 'shouldstay' + } + } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.token': 'redacted', + 'http.payload.foo.bar.authorization': 'redacted', + 'http.payload.foo.bar.valid': 'valid', + 'http.payload.foo.baz.password': 'redacted', + 'http.payload.foo.baz.x-authorization': 'redacted', + 'http.payload.foo.baz.data': 'shouldstay' + }) + }) + + it('should redact banned keys even if they are objects', () => { + const input = { + foo: { + authorization: { + token: 'tokenpleaseredact', + authorization: 'pleaseredact', + valid: 'valid' + }, + baz: { + password: 'shouldgo', + 'x-authorization': 'shouldbegone', + data: 'shouldstay' + } + } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.authorization': 'redacted', + 'http.payload.foo.baz.password': 'redacted', + 'http.payload.foo.baz.x-authorization': 'redacted', + 'http.payload.foo.baz.data': 'shouldstay' + }) + }) + }) + + describe('escaping', () => { + it('should escape `.` characters in individual keys', () => { + const input = { 'foo.bar': { baz: 'quux' } } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo\\.bar.baz': 'quux' + }) + }) + }) + + describe('parsing', () => { + it('should transform null values to "null" string', () => { + const input = { foo: 'bar', baz: null } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'bar', + 'http.payload.baz': 'null' + }) + }) + + it('should transform undefined values to "undefined" string', () => { + const input = { foo: 'bar', baz: undefined } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'bar', + 'http.payload.baz': 'undefined' + }) + }) + + it('should transform boolean values to strings', () => { + const input = { foo: true, bar: false } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo': 'true', + 'http.payload.bar': 'false' + }) + }) + + it('should decode buffers as UTF-8', () => { + const input = { foo: Buffer.from('bar') } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.foo': 'bar' }) + }) + + it('should provide tags from simple JSON objects, casting to strings where necessary', () => { + const input = { + foo: { bar: { baz: 1, quux: 2 } }, + asimplestring: 'isastring', + anullvalue: null, + anundefined: undefined + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.baz': '1', + 'http.payload.foo.bar.quux': '2', + 'http.payload.asimplestring': 'isastring', + 'http.payload.anullvalue': 'null', + 'http.payload.anundefined': 'undefined' + }) + }) + + it('should index tags when encountering arrays', () => { + const input = { foo: { bar: { list: ['v0', 'v1', 'v2'] } } } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ + 'http.payload.foo.bar.list.0': 'v0', + 'http.payload.foo.bar.list.1': 'v1', + 'http.payload.foo.bar.list.2': 'v2' + }) + }) + + it('should not replace a real value at max depth', () => { + const input = { + 1: { 2: { 3: { 4: { 5: { 6: { 7: { 8: { 9: { 10: 11 } } } } } } } } } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.1.2.3.4.5.6.7.8.9.10': '11' }) + }) + + it('should truncate paths beyond max depth', () => { + const input = { + 1: { 2: { 3: { 4: { 5: { 6: { 7: { 8: { 9: { 10: { 11: 'too much' } } } } } } } } } } + } + const tags = tagsFromObject(input, defaultOpts) + expect(tags).to.deep.equal({ 'http.payload.1.2.3.4.5.6.7.8.9.10': 'truncated' }) + }) + }) +}) + +describe('Tagging orchestration', () => { + it('should use the request config when given the request prefix', () => { + const config = { + request: ['$.request'], + response: ['$.response'], + expand: [] + } + const input = { + request: 'foo', + response: 'bar' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: PAYLOAD_TAG_REQUEST_PREFIX }) + expect(tags).to.have.property(`${PAYLOAD_TAG_REQUEST_PREFIX}.request`, 'redacted') + expect(tags).to.have.property(`${PAYLOAD_TAG_REQUEST_PREFIX}.response`, 'bar') + }) + + it('should use the response config when given the response prefix', () => { + const config = { + request: ['$.request'], + response: ['$.response'], + expand: [] + } + const input = { + request: 'foo', + response: 'bar' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: PAYLOAD_TAG_RESPONSE_PREFIX }) + expect(tags).to.have.property(`${PAYLOAD_TAG_RESPONSE_PREFIX}.response`, 'redacted') + expect(tags).to.have.property(`${PAYLOAD_TAG_RESPONSE_PREFIX}.request`, 'foo') + }) + + it('should apply expansion rules', () => { + const config = { + request: [], + response: [], + expand: ['$.request', '$.response', '$.invalid'] + } + const input = { + request: '{ "foo": "bar" }', + response: '{ "baz": "quux" }', + invalid: '{ invalid JSON }', + untargeted: '{ "foo": "bar" }' + } + const tags = computeTags(config, input, { maxDepth: 10, prefix: 'foo' }) + expect(tags).to.have.property('foo.request.foo', 'bar') + expect(tags).to.have.property('foo.response.baz', 'quux') + expect(tags).to.have.property('foo.invalid', '{ invalid JSON }') + expect(tags).to.have.property('foo.untargeted', '{ "foo": "bar" }') + }) +}) diff --git a/yarn.lock b/yarn.lock index b133bdb94c3..fba90324908 100644 --- a/yarn.lock +++ b/yarn.lock @@ -526,6 +526,24 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.15" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz" + integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jsep-plugin/assignment@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jsep-plugin/assignment/-/assignment-1.2.1.tgz#07277bdd7862451a865d391e2142efba33f46c9b" + integrity sha512-gaHqbubTi29aZpVbBlECRpmdia+L5/lh2BwtIJTmtxdbecEyyX/ejAOg7eQDGNvGOUmPY7Z2Yxdy9ioyH/VJeA== + +"@jsep-plugin/regex@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@jsep-plugin/regex/-/regex-1.0.3.tgz#3aeaa2e5fa45d89de116aeafbfa41c95935b7f6d" + integrity sha512-XfZgry4DwEZvSFtS/6Y+R48D7qJYJK6R9/yJFyUFHCIUMEEHuJ4X95TDgJp5QkmzfLYvapMPzskV5HpIDrREug== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1531,7 +1549,7 @@ deep-eql@^4.1.2: deep-is@^0.1.3: version "0.1.4" - resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== default-require-extensions@^3.0.0: @@ -1968,7 +1986,7 @@ espree@^9.6.0, espree@^9.6.1: esprima@^4.0.0, esprima@~4.0.0: version "4.0.1" - resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.4.2: @@ -2064,7 +2082,7 @@ fast-json-stable-stringify@^2.0.0: fast-levenshtein@^2.0.6: version "2.0.6" - resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fastq@^1.6.0: @@ -2949,6 +2967,11 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +jsep@^1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/jsep/-/jsep-1.3.8.tgz#facb6eb908d085d71d950bd2b24b757c7b8a46d7" + integrity sha512-qofGylTGgYj9gZFsHuyWAN4jr35eJ66qJCK4eKDnldohuUoQFbU3iZn2zjvEbd9wOAhP9Wx5DsAAduTyE1PSWQ== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz" @@ -2981,6 +3004,15 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonpath-plus@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/jsonpath-plus/-/jsonpath-plus-9.0.0.tgz#bb8703ee481531142bca8dee9a42fe72b8358a7f" + integrity sha512-bqE77VIDStrOTV/czspZhTn+o27Xx9ZJRGVkdVShEtPoqsIx5yALv3lWVU6y+PqYvWPJNWE7ORCQheQkEe0DDA== + dependencies: + "@jsep-plugin/assignment" "^1.2.1" + "@jsep-plugin/regex" "^1.0.3" + jsep "^1.3.8" + jszip@^3.5.0: version "3.10.1" resolved "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz" @@ -3950,6 +3982,11 @@ reusify@^1.0.4: resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== + rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" @@ -4166,7 +4203,7 @@ source-map-support@^0.5.16: source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== source-map@^0.7.4: