diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 9565bc3f553..3132fc974dc 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -15,6 +15,7 @@ require,int64-buffer,MIT,Copyright 2015-2016 Yusuke Kawasaki require,ipaddr.js,MIT,Copyright 2011-2017 whitequark require,istanbul-lib-coverage,BSD-3-Clause,Copyright 2012-2015 Yahoo! Inc. require,jest-docblock,MIT,Copyright Meta Platforms, Inc. and affiliates. +require,jsonpath,MIT,Copyright 2014-2016 David Chester 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 @@ -29,6 +30,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 68959b6268a..9ea877b9b19 100644 --- a/index.d.ts +++ b/index.d.ts @@ -712,6 +712,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 4ff6dcdf759..f1524d7928d 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "ipaddr.js": "^2.1.0", "istanbul-lib-coverage": "3.2.0", "jest-docblock": "^29.7.0", + "jsonpath": "^1.1.1", "koalas": "^1.0.2", "limiter": "1.1.5", "lodash.sortby": "^4.7.0", @@ -99,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" @@ -119,7 +121,7 @@ "eslint": "^8.23.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.8.0", - "eslint-plugin-mocha": "^10.1.0", + "eslint-plugin-mocha": "<10.3.0", "eslint-plugin-n": "^15.7.0", "eslint-plugin-promise": "^3.6.0", "eslint-plugin-standard": "^3.0.1", diff --git a/packages/datadog-plugin-aws-sdk/src/base.js b/packages/datadog-plugin-aws-sdk/src/base.js index 21e4bfa47f6..9aa78489d91 100644 --- a/packages/datadog-plugin-aws-sdk/src/base.js +++ b/packages/datadog-plugin-aws-sdk/src/base.js @@ -4,9 +4,11 @@ const analyticsSampler = require('../../dd-trace/src/analytics_sampler') const ClientPlugin = require('../../dd-trace/src/plugins/client') const { storage } = require('../../datadog-core') const { isTrue } = require('../../dd-trace/src/util') +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() @@ -19,6 +21,12 @@ 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) @@ -50,6 +58,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) @@ -109,6 +123,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, @@ -116,6 +131,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 ee5191ddabc..b840bad3225 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 27f194abc7e..80434286de6 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.spec.js @@ -80,6 +80,246 @@ describe('Sns', () => { }) } + describe('with payload tagging', () => { + before(() => { + parentId = '0' + spanId = '0' + + return agent.load('aws-sdk', {}, { + cloudPayloadTagging: { + request: ['$.MessageAttributes.foo', '$.MessageAttributes.redacted.StringValue.foo'], + response: ['$.MessageId', '$.Attributes.DisplayName'] + } + }) + }) + + before(done => { + tracer = require('../../dd-trace') + tracer.use('aws-sdk') + + createResources('TestQueue', 'TestTopic', done) + }) + + after(done => { + sns.deleteTopic({ TopicArn }, done) + }) + + after(done => { + sqs.deleteQueue({ QueueUrl }, done) + }) + + after(() => { + return agent.close({ ritmReset: false, wipe: true }) + }) + + 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 test this + 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.skip('subscription confirmation tokens', () => { + // TODO test this + it('redacts tokens 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', + 'aws.sns.topic_arn': TopicArn, + 'topicname': 'TestTopic', + 'region': 'us-east-1', + 'aws.request.body.Token': 'redacted', + 'aws.request.body.TopicArn': 'TestTopic' + }) + }).then(done, done) + + sns.confirmSubscription({ + TopicArn, + Token: '1234' + }, e => e && done(e)) + }) + it('redacts tokens in response', () => { + + }) + }) + }) + }) + describe('no configuration', () => { before(() => { parentId = '0' @@ -252,7 +492,7 @@ describe('Sns', () => { }) 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 ae890f688c5..b75ae31093f 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -1,6 +1,7 @@ 'use strict' const fs = require('fs') +const jp = require('jsonpath') const os = require('os') const uuid = require('crypto-randomuuid') const URL = require('url').URL @@ -18,6 +19,7 @@ const { updateConfig } = require('./telemetry') const telemetryMetrics = require('./telemetry/metrics') const { getIsGCPFunction, getIsAzureFunctionConsumptionPlan } = require('./serverless') const { ORIGIN_KEY } = require('./constants') +const { appendRules } = require('./payload-tagging/config') const tracerMetrics = telemetryMetrics.manager.namespace('tracers') @@ -119,6 +121,26 @@ function validateNamingVersion (versionString) { return versionString } +/** + * Given a string of comma-separated paths, return the array of paths if + * all paths are valid JSON paths, or undefined if any path is invalid. + * + * @param {string} input + * @returns {[string] | undefined} + */ +function validJSONPathsOrUndef (input) { + if (input === 'all') return [] + const rules = input.split(',') + for (const rule of rules) { + try { + jp.parse(rule) + } catch (e) { + return undefined + } + } + return rules +} + // Shallow clone with property name remapping function remapify (input, mappings) { if (!input) return @@ -287,6 +309,26 @@ class Config { null ) + const DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING = validJSONPathsOrUndef( + coalesce( + process.env.DD_TRACE_CLOUD_REQUEST_PAYLOAD_TAGGING, + options.cloudPayloadTagging?.request, + '' + )) + + const DD_TRACE_CLOUD_RESPONSE_PAYLOAD_TAGGING = validJSONPathsOrUndef( + 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 + ) + const sampler = { rules: coalesce( options.samplingRules, @@ -347,6 +389,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 89dcbdf4c7e..542b27ef385 100644 --- a/packages/dd-trace/src/constants.js +++ b/packages/dd-trace/src/constants.js @@ -30,5 +30,8 @@ module.exports = { PEER_SERVICE_SOURCE_KEY: '_dd.peer.service.source', PEER_SERVICE_REMAP_KEY: '_dd.peer.service.remapped_from', SCI_REPOSITORY_URL: '_dd.git.repository_url', - SCI_COMMIT_SHA: '_dd.git.commit.sha' + SCI_COMMIT_SHA: '_dd.git.commit.sha', + 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..5f06db819e8 --- /dev/null +++ b/packages/dd-trace/src/payload-tagging/index.js @@ -0,0 +1,88 @@ +const rfdc = require('rfdc')({ proto: false, circles: false }) + +const { + PAYLOAD_TAG_REQUEST_PREFIX, + PAYLOAD_TAG_RESPONSE_PREFIX +} = require('../constants') + +const jsonpath = require('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 expandValue (value) { + if (typeof value === 'string') { + try { + return JSON.parse(value) + } catch (e) { + return value + } + } + 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.apply(object, rule, expandValue) + } +} + +/** + * Apply redaction to all redaction JSONPath queries + * + * @param {Object} object + * @param {[String]} redactionRules + */ +function redact (object, redactionRules) { + for (const rule of redactionRules) { + jsonpath.apply(object, rule, () => '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 69bae6a4ec5..a28923c31e0 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -1804,4 +1804,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..29f3b1dd0b4 --- /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..254dee4b540 --- /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 09544b060cb..1ca51da9bfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1725,9 +1725,9 @@ deep-equal@^2.2.2: which-collection "^1.0.1" which-typed-array "^1.1.9" -deep-is@^0.1.3: +deep-is@^0.1.3, 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: @@ -2038,6 +2038,18 @@ escape-string-regexp@^2.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +escodegen@^1.8.1: + version "1.14.3" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" + integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + eslint-config-standard@^17.1.0: version "17.1.0" resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz#40ffb8595d47a6b242e07cbfd49dc211ed128975" @@ -2085,13 +2097,13 @@ eslint-plugin-import@^2.8.0: resolve "^1.22.0" tsconfig-paths "^3.14.1" -eslint-plugin-mocha@^10.1.0: - version "10.1.0" - resolved "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz" - integrity sha512-xLqqWUF17llsogVOC+8C6/jvQ+4IoOREbN7ZCHuOHuD6cT5cDD4h7f2LgsZuzMAiwswWE21tO7ExaknHVDrSkw== +eslint-plugin-mocha@<10.3.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.2.0.tgz#15b05ce5be4b332bb0d76826ec1c5ebf67102ad6" + integrity sha512-ZhdxzSZnd1P9LqDPF0DBcFLpRIGdh1zkF2JHnQklKQOvrQtT73kdP5K9V2mzvbLR+cCAO9OI48NXK/Ax9/ciCQ== dependencies: eslint-utils "^3.0.0" - rambda "^7.1.0" + rambda "^7.4.0" eslint-plugin-n@^15.7.0: version "15.7.0" @@ -2213,9 +2225,14 @@ espree@^9.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" -esprima@^4.0.0, esprima@~4.0.0: +esprima@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b" + integrity sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A== + +esprima@^4.0.0, esprima@^4.0.1, 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.0: @@ -2232,6 +2249,11 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" +estraverse@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + estraverse@^5.1.0, estraverse@^5.2.0: version "5.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" @@ -2320,9 +2342,9 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-levenshtein@^2.0.6: +fast-levenshtein@^2.0.6, 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: @@ -3315,6 +3337,15 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonpath@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.1.1.tgz#0ca1ed8fb65bb3309248cc9d5466d12d5b0b9901" + integrity sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w== + dependencies: + esprima "1.2.2" + static-eval "2.0.2" + underscore "1.12.1" + jszip@^3.5.0: version "3.10.1" resolved "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz" @@ -3363,6 +3394,14 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA== + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + libtap@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/libtap/-/libtap-1.4.0.tgz" @@ -3874,6 +3913,18 @@ optimist@~0.3.5: dependencies: wordwrap "~0.0.2" +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + optionator@^0.9.1: version "0.9.1" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz" @@ -4049,6 +4100,11 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== + pretty-bytes@^5.3.0: version "5.6.0" resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz" @@ -4148,10 +4204,10 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -rambda@^7.1.0: - version "7.4.0" - resolved "https://registry.npmjs.org/rambda/-/rambda-7.4.0.tgz" - integrity sha512-A9hihu7dUTLOUCM+I8E61V4kRXnN4DwYeK0DwCBydC1MqNI1PidyAtbtpsJlBBzK4icSctEcCQ1bGcLpBuETUQ== +rambda@^7.4.0: + version "7.5.0" + resolved "https://registry.yarnpkg.com/rambda/-/rambda-7.5.0.tgz#1865044c59bc0b16f63026c6e5a97e4b1bbe98fe" + integrity sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA== randombytes@^2.1.0: version "2.1.0" @@ -4366,6 +4422,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" @@ -4589,9 +4650,9 @@ source-map-support@^0.5.16: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@^0.6.1: +source-map@^0.6.0, source-map@^0.6.1, 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: @@ -4623,6 +4684,13 @@ stack-utils@^2.0.2, stack-utils@^2.0.4: dependencies: escape-string-regexp "^2.0.0" +static-eval@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42" + integrity sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg== + dependencies: + escodegen "^1.8.1" + statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" @@ -4952,6 +5020,13 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg== + dependencies: + prelude-ls "~1.1.2" + type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" @@ -5046,6 +5121,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +underscore@1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" + integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" @@ -5198,7 +5278,7 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" -word-wrap@^1.2.3: +word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==