dd-trace 5.86.0 → 5.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +18 -3
- package/package.json +1 -1
- package/packages/datadog-instrumentations/src/cucumber.js +14 -0
- package/packages/datadog-instrumentations/src/helpers/hooks.js +1 -0
- package/packages/datadog-instrumentations/src/http/client.js +119 -1
- package/packages/datadog-instrumentations/src/jest.js +104 -4
- package/packages/datadog-instrumentations/src/mocha/utils.js +6 -0
- package/packages/datadog-instrumentations/src/mysql2.js +131 -64
- package/packages/datadog-instrumentations/src/playwright.js +8 -0
- package/packages/datadog-instrumentations/src/stripe.js +92 -0
- package/packages/datadog-instrumentations/src/vitest.js +11 -0
- package/packages/datadog-plugin-azure-functions/src/index.js +53 -37
- package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
- package/packages/dd-trace/src/appsec/addresses.js +11 -0
- package/packages/dd-trace/src/appsec/channels.js +5 -1
- package/packages/dd-trace/src/appsec/downstream_requests.js +302 -0
- package/packages/dd-trace/src/appsec/index.js +103 -0
- package/packages/dd-trace/src/appsec/rasp/ssrf.js +66 -4
- package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +14 -1
- package/packages/dd-trace/src/config/defaults.js +2 -0
- package/packages/dd-trace/src/config/index.js +6 -0
- package/packages/dd-trace/src/config/supported-configurations.json +2 -0
- package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +47 -2
- package/packages/dd-trace/src/debugger/devtools_client/index.js +75 -23
- package/packages/dd-trace/src/debugger/devtools_client/remote_config.js +23 -1
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/collector.js +3 -3
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +168 -36
- package/packages/dd-trace/src/debugger/devtools_client/snapshot/processor.js +18 -0
- package/packages/dd-trace/src/exporters/common/agents.js +1 -1
- package/packages/dd-trace/src/llmobs/constants/writers.js +1 -1
- package/packages/dd-trace/src/llmobs/sdk.js +34 -5
- package/packages/dd-trace/src/plugins/database.js +42 -43
- package/packages/dd-trace/src/plugins/outbound.js +27 -2
- package/packages/dd-trace/src/plugins/tracing.js +39 -4
- package/packages/dd-trace/src/plugins/util/inferred_proxy.js +7 -0
- package/packages/dd-trace/src/plugins/util/web.js +8 -7
- package/packages/dd-trace/src/startup-log.js +2 -2
- package/packages/dd-trace/src/plugins/util/serverless.js +0 -8
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const web = require('../plugins/util/web')
|
|
4
|
+
const log = require('../log')
|
|
5
|
+
const {
|
|
6
|
+
HTTP_OUTGOING_METHOD,
|
|
7
|
+
HTTP_OUTGOING_HEADERS,
|
|
8
|
+
HTTP_OUTGOING_RESPONSE_STATUS,
|
|
9
|
+
HTTP_OUTGOING_RESPONSE_HEADERS,
|
|
10
|
+
HTTP_OUTGOING_RESPONSE_BODY,
|
|
11
|
+
} = require('./addresses')
|
|
12
|
+
|
|
13
|
+
const KNUTH_FACTOR = 11400714819323199488n // eslint-disable-line unicorn/numeric-separators-style
|
|
14
|
+
const UINT64_MAX = (1n << 64n) - 1n
|
|
15
|
+
|
|
16
|
+
let config
|
|
17
|
+
let samplingRate
|
|
18
|
+
let globalRequestCounter
|
|
19
|
+
let bodyAnalysisCount
|
|
20
|
+
let downstreamAnalysisCount
|
|
21
|
+
let redirectBodyCollectionDecisions
|
|
22
|
+
|
|
23
|
+
function enable (_config) {
|
|
24
|
+
config = _config
|
|
25
|
+
globalRequestCounter = 0n
|
|
26
|
+
bodyAnalysisCount = new WeakMap()
|
|
27
|
+
downstreamAnalysisCount = new WeakMap()
|
|
28
|
+
redirectBodyCollectionDecisions = new WeakMap()
|
|
29
|
+
|
|
30
|
+
const bodyAnalysisSampleRate = config.appsec.apiSecurity?.downstreamBodyAnalysisSampleRate
|
|
31
|
+
samplingRate = Math.min(Math.max(bodyAnalysisSampleRate, 0), 1)
|
|
32
|
+
|
|
33
|
+
if (samplingRate !== bodyAnalysisSampleRate) {
|
|
34
|
+
log.warn(
|
|
35
|
+
'DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE value is %s and it\'s out of range',
|
|
36
|
+
bodyAnalysisSampleRate)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function disable () {
|
|
41
|
+
config = null
|
|
42
|
+
globalRequestCounter = null
|
|
43
|
+
bodyAnalysisCount = null
|
|
44
|
+
downstreamAnalysisCount = null
|
|
45
|
+
redirectBodyCollectionDecisions = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check we have a stored redirect body collection decision for a given URL.
|
|
50
|
+
* @param {import('http').IncomingMessage} req outgoing request.
|
|
51
|
+
* @param {string} outgoingUrl the URL being requested.
|
|
52
|
+
* @returns {boolean} the stored decision
|
|
53
|
+
*/
|
|
54
|
+
function consumeRedirectBodyCollectionDecision (req, outgoingUrl) {
|
|
55
|
+
const decisions = redirectBodyCollectionDecisions.get(req)
|
|
56
|
+
if (!decisions) return false
|
|
57
|
+
|
|
58
|
+
return decisions.delete(outgoingUrl)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stores a redirect body collection decision for a follow-up request.
|
|
63
|
+
* @param {import('http').IncomingMessage} req outgoing request.
|
|
64
|
+
* @param {string} redirectUrl the URL to redirect to.
|
|
65
|
+
*/
|
|
66
|
+
function storeRedirectBodyCollectionDecision (req, redirectUrl) {
|
|
67
|
+
let decisions = redirectBodyCollectionDecisions.get(req)
|
|
68
|
+
|
|
69
|
+
if (!decisions) {
|
|
70
|
+
decisions = new Set()
|
|
71
|
+
redirectBodyCollectionDecisions.set(req, decisions)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
decisions.add(redirectUrl)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Determines whether the current downstream request/responses bodies should be sampled for analysis.
|
|
79
|
+
* @param {import('http').IncomingMessage} req outgoing request.
|
|
80
|
+
* @param {string} outgoingUrl the URL being requested (to check for redirect decisions).
|
|
81
|
+
* @returns {boolean} true when the downstream response body should be captured.
|
|
82
|
+
*/
|
|
83
|
+
function shouldSampleBody (req, outgoingUrl) {
|
|
84
|
+
// Check if there's a stored decision from a previous redirect
|
|
85
|
+
const storedDecision = consumeRedirectBodyCollectionDecision(req, outgoingUrl)
|
|
86
|
+
if (storedDecision) return true
|
|
87
|
+
|
|
88
|
+
globalRequestCounter = (globalRequestCounter + 1n) & UINT64_MAX
|
|
89
|
+
|
|
90
|
+
const currentCount = bodyAnalysisCount.get(req) || 0
|
|
91
|
+
if (currentCount >= config.appsec.apiSecurity?.maxDownstreamRequestBodyAnalysis) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hashed = (globalRequestCounter * KNUTH_FACTOR) % UINT64_MAX
|
|
96
|
+
// Replace 1000n with the accuraccy that we want to maintain
|
|
97
|
+
const threshold = (UINT64_MAX * BigInt(Math.round(samplingRate * 1000))) / 1000n
|
|
98
|
+
|
|
99
|
+
const shouldCollectBody = hashed <= threshold
|
|
100
|
+
|
|
101
|
+
// Track body analysis count if we're sampling the response body
|
|
102
|
+
if (shouldCollectBody) {
|
|
103
|
+
incrementBodyAnalysisCount(req)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return shouldCollectBody
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Increments the number of downstream body analyses performed for the given request.
|
|
111
|
+
* @param {import('http').IncomingMessage} req outgoing request.
|
|
112
|
+
*/
|
|
113
|
+
function incrementBodyAnalysisCount (req) {
|
|
114
|
+
const currentCount = bodyAnalysisCount.get(req) || 0
|
|
115
|
+
bodyAnalysisCount.set(req, currentCount + 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
*
|
|
120
|
+
* @param {object} headers
|
|
121
|
+
* @returns {object} the headers with all keys converted to lowercase
|
|
122
|
+
*/
|
|
123
|
+
function lowercaseHeaderKeys (headers) {
|
|
124
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extracts request data from the context for WAF analysis
|
|
129
|
+
* @param {object} ctx context for the outgoing downstream request.
|
|
130
|
+
* @returns {object} a map of addresses and request data.
|
|
131
|
+
*/
|
|
132
|
+
function extractRequestData (ctx) {
|
|
133
|
+
const addresses = {}
|
|
134
|
+
|
|
135
|
+
const options = ctx?.args?.options || {}
|
|
136
|
+
|
|
137
|
+
addresses[HTTP_OUTGOING_METHOD] = getMethod(options.method)
|
|
138
|
+
|
|
139
|
+
const headers = options?.headers
|
|
140
|
+
if (headers && Object.keys(headers).length > 0) {
|
|
141
|
+
addresses[HTTP_OUTGOING_HEADERS] = lowercaseHeaderKeys(headers)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return addresses
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Checks if a response is a redirect
|
|
149
|
+
* @param {import('http').IncomingMessage} req incoming server request.
|
|
150
|
+
* @param {import('http').IncomingMessage} res downstream response object.
|
|
151
|
+
* @returns {boolean} is redirect.
|
|
152
|
+
*/
|
|
153
|
+
function handleRedirectResponse (req, res) {
|
|
154
|
+
const isRedirect = res.statusCode >= 300 && res.statusCode < 400
|
|
155
|
+
const redirectLocation = res.headers?.location || ''
|
|
156
|
+
|
|
157
|
+
if (isRedirect && redirectLocation) {
|
|
158
|
+
// Store the body collection decision for the redirect target
|
|
159
|
+
storeRedirectBodyCollectionDecision(req, redirectLocation)
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extracts response data for WAF analysis.
|
|
168
|
+
* @param {import('http').IncomingMessage} res downstream response object.
|
|
169
|
+
* @param {Buffer|string|object|null} responseBody response body.
|
|
170
|
+
* @returns {object} a map of addresses and response data.
|
|
171
|
+
*/
|
|
172
|
+
function extractResponseData (res, responseBody) {
|
|
173
|
+
const addresses = {}
|
|
174
|
+
|
|
175
|
+
if (res.statusCode) {
|
|
176
|
+
addresses[HTTP_OUTGOING_RESPONSE_STATUS] = String(res.statusCode)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const headers = res.headers
|
|
180
|
+
if (headers && Object.keys(headers).length > 0) {
|
|
181
|
+
addresses[HTTP_OUTGOING_RESPONSE_HEADERS] = headers
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (responseBody) {
|
|
185
|
+
// Parse the body based on content-type
|
|
186
|
+
const contentType = res.headers?.['content-type']
|
|
187
|
+
const body = parseBody(responseBody, contentType)
|
|
188
|
+
|
|
189
|
+
if (body) {
|
|
190
|
+
addresses[HTTP_OUTGOING_RESPONSE_BODY] = body
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return addresses
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tracks how many downstream analyses were executed for a given request and updates tracing tags.
|
|
199
|
+
* @param {import('http').IncomingMessage} req outgoing request.
|
|
200
|
+
*/
|
|
201
|
+
function incrementDownstreamAnalysisCount (req) {
|
|
202
|
+
const currentCount = downstreamAnalysisCount.get(req) || 0
|
|
203
|
+
downstreamAnalysisCount.set(req, currentCount + 1)
|
|
204
|
+
|
|
205
|
+
const span = web.root(req)
|
|
206
|
+
|
|
207
|
+
if (span) {
|
|
208
|
+
span.setTag('_dd.appsec.downstream_request', currentCount + 1)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Returns the HTTP method to use for a downstream request, defaulting to GET.
|
|
214
|
+
* @param {string} method method supplied in the outgoing request options.
|
|
215
|
+
* @returns {string} validated HTTP method.
|
|
216
|
+
*/
|
|
217
|
+
function getMethod (method) {
|
|
218
|
+
return typeof method === 'string' && method ? method : 'GET'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parses a downstream response body.
|
|
223
|
+
* @param {Buffer|string|object|null} body raw response body
|
|
224
|
+
* @param {string|null} contentType response content-type used to select the parser.
|
|
225
|
+
* @returns {object|null} parsed body object or null when not supported.
|
|
226
|
+
*/
|
|
227
|
+
function parseBody (body, contentType) {
|
|
228
|
+
if (!body || !contentType) {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const mime = extractMimeType(contentType)
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (mime === 'application/json' || mime === 'text/json') {
|
|
236
|
+
if (typeof body === 'string') {
|
|
237
|
+
return JSON.parse(body)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (Buffer.isBuffer(body)) {
|
|
241
|
+
return JSON.parse(body.toString('utf8'))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (mime === 'application/x-www-form-urlencoded') {
|
|
248
|
+
const formBody = Buffer.isBuffer(body) ? body.toString('utf8') : String(body)
|
|
249
|
+
const params = new URLSearchParams(formBody)
|
|
250
|
+
const result = {}
|
|
251
|
+
for (const [key, value] of params.entries()) {
|
|
252
|
+
if (key in result) {
|
|
253
|
+
const existing = result[key]
|
|
254
|
+
if (Array.isArray(existing)) {
|
|
255
|
+
existing.push(value)
|
|
256
|
+
} else {
|
|
257
|
+
result[key] = [existing, value]
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
result[key] = value
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// multipart/form-data is mentioned in RFC but parsing is complex.
|
|
268
|
+
// Other content-types also discarded per RFC
|
|
269
|
+
|
|
270
|
+
return null
|
|
271
|
+
} catch {
|
|
272
|
+
// Parsing failed: return null to avoid sending malformed body to WAF
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Extracts the MIME type portion of a content-type header value.
|
|
279
|
+
* @param {string|null} contentType raw content-type header value.
|
|
280
|
+
* @returns {string|null} lowercase mime type
|
|
281
|
+
*/
|
|
282
|
+
function extractMimeType (contentType) {
|
|
283
|
+
if (typeof contentType !== 'string') {
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return contentType.split(';', 1)[0].trim().toLowerCase()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
enable,
|
|
292
|
+
disable,
|
|
293
|
+
shouldSampleBody,
|
|
294
|
+
handleRedirectResponse,
|
|
295
|
+
incrementDownstreamAnalysisCount,
|
|
296
|
+
extractRequestData,
|
|
297
|
+
extractResponseData,
|
|
298
|
+
// exports for tests
|
|
299
|
+
parseBody,
|
|
300
|
+
getMethod,
|
|
301
|
+
storeRedirectBodyCollectionDecision,
|
|
302
|
+
}
|
|
@@ -30,6 +30,9 @@ const {
|
|
|
30
30
|
routerParam,
|
|
31
31
|
fastifyResponseChannel,
|
|
32
32
|
fastifyPathParams,
|
|
33
|
+
stripeCheckoutSessionCreate,
|
|
34
|
+
stripePaymentIntentCreate,
|
|
35
|
+
stripeConstructEvent,
|
|
33
36
|
} = require('./channels')
|
|
34
37
|
const waf = require('./waf')
|
|
35
38
|
const addresses = require('./addresses')
|
|
@@ -92,6 +95,9 @@ function enable (_config) {
|
|
|
92
95
|
fastifyResponseChannel.subscribe(onResponseBody)
|
|
93
96
|
responseWriteHead.subscribe(onResponseWriteHead)
|
|
94
97
|
responseSetHeader.subscribe(onResponseSetHeader)
|
|
98
|
+
stripeCheckoutSessionCreate.subscribe(onStripeCheckoutSessionCreate)
|
|
99
|
+
stripePaymentIntentCreate.subscribe(onStripePaymentIntentCreate)
|
|
100
|
+
stripeConstructEvent.subscribe(onStripeConstructEvent)
|
|
95
101
|
|
|
96
102
|
isEnabled = true
|
|
97
103
|
config = _config
|
|
@@ -382,6 +388,100 @@ function onResponseSetHeader ({ res, abortController }) {
|
|
|
382
388
|
}
|
|
383
389
|
}
|
|
384
390
|
|
|
391
|
+
function onStripeCheckoutSessionCreate (payload) {
|
|
392
|
+
if (payload?.mode !== 'payment') return
|
|
393
|
+
|
|
394
|
+
waf.run({
|
|
395
|
+
persistent: {
|
|
396
|
+
[addresses.PAYMENT_CREATION]: {
|
|
397
|
+
integration: 'stripe',
|
|
398
|
+
id: payload.id,
|
|
399
|
+
amount_total: payload.amount_total,
|
|
400
|
+
client_reference_id: payload.client_reference_id,
|
|
401
|
+
currency: payload.currency,
|
|
402
|
+
'discounts.coupon': payload.discounts?.[0]?.coupon,
|
|
403
|
+
'discounts.promotion_code': payload.discounts?.[0]?.promotion_code,
|
|
404
|
+
livemode: payload.livemode,
|
|
405
|
+
'total_details.amount_discount': payload.total_details?.amount_discount,
|
|
406
|
+
'total_details.amount_shipping': payload.total_details?.amount_shipping,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function onStripePaymentIntentCreate (payload) {
|
|
413
|
+
if (payload === null || typeof payload !== 'object') return
|
|
414
|
+
|
|
415
|
+
waf.run({
|
|
416
|
+
persistent: {
|
|
417
|
+
[addresses.PAYMENT_CREATION]: {
|
|
418
|
+
integration: 'stripe',
|
|
419
|
+
id: payload.id,
|
|
420
|
+
amount: payload.amount,
|
|
421
|
+
currency: payload.currency,
|
|
422
|
+
livemode: payload.livemode,
|
|
423
|
+
payment_method: payload.payment_method,
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function onStripeConstructEvent (payload) {
|
|
430
|
+
const object = payload?.data?.object
|
|
431
|
+
if (object === null || typeof object !== 'object') return
|
|
432
|
+
|
|
433
|
+
let persistent
|
|
434
|
+
|
|
435
|
+
switch (payload.type) {
|
|
436
|
+
case 'payment_intent.succeeded':
|
|
437
|
+
persistent = {
|
|
438
|
+
[addresses.PAYMENT_SUCCESS]: {
|
|
439
|
+
integration: 'stripe',
|
|
440
|
+
id: object.id,
|
|
441
|
+
amount: object.amount,
|
|
442
|
+
currency: object.currency,
|
|
443
|
+
livemode: object.livemode,
|
|
444
|
+
payment_method: object.payment_method,
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
case 'payment_intent.payment_failed':
|
|
450
|
+
persistent = {
|
|
451
|
+
[addresses.PAYMENT_FAILURE]: {
|
|
452
|
+
integration: 'stripe',
|
|
453
|
+
id: object.id,
|
|
454
|
+
amount: object.amount,
|
|
455
|
+
currency: object.currency,
|
|
456
|
+
'last_payment_error.code': object.last_payment_error?.code,
|
|
457
|
+
'last_payment_error.decline_code': object.last_payment_error?.decline_code,
|
|
458
|
+
'last_payment_error.payment_method.id': object.last_payment_error?.payment_method?.id,
|
|
459
|
+
'last_payment_error.payment_method.type': object.last_payment_error?.payment_method?.type,
|
|
460
|
+
livemode: object.livemode,
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
case 'payment_intent.canceled':
|
|
466
|
+
persistent = {
|
|
467
|
+
[addresses.PAYMENT_CANCELLATION]: {
|
|
468
|
+
integration: 'stripe',
|
|
469
|
+
id: object.id,
|
|
470
|
+
amount: object.amount,
|
|
471
|
+
cancellation_reason: object.cancellation_reason,
|
|
472
|
+
currency: object.currency,
|
|
473
|
+
livemode: object.livemode,
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
default:
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
waf.run({ persistent })
|
|
483
|
+
}
|
|
484
|
+
|
|
385
485
|
function handleResults (actions, req, res, rootSpan, abortController) {
|
|
386
486
|
if (!actions || !req || !res || !rootSpan || !abortController) return
|
|
387
487
|
|
|
@@ -427,6 +527,9 @@ function disable () {
|
|
|
427
527
|
if (fastifyResponseChannel.hasSubscribers) fastifyResponseChannel.unsubscribe(onResponseBody)
|
|
428
528
|
if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead)
|
|
429
529
|
if (responseSetHeader.hasSubscribers) responseSetHeader.unsubscribe(onResponseSetHeader)
|
|
530
|
+
if (stripeCheckoutSessionCreate.hasSubscribers) stripeCheckoutSessionCreate.unsubscribe(onStripeCheckoutSessionCreate)
|
|
531
|
+
if (stripePaymentIntentCreate.hasSubscribers) stripePaymentIntentCreate.unsubscribe(onStripePaymentIntentCreate)
|
|
532
|
+
if (stripeConstructEvent.hasSubscribers) stripeConstructEvent.unsubscribe(onStripeConstructEvent)
|
|
430
533
|
}
|
|
431
534
|
|
|
432
535
|
// this is faster than Object.keys().length === 0
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { format } = require('url')
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
httpClientRequestStart,
|
|
6
|
+
httpClientResponseFinish,
|
|
7
|
+
} = require('../channels')
|
|
5
8
|
const { storage } = require('../../../../datadog-core')
|
|
6
9
|
const addresses = require('../addresses')
|
|
7
10
|
const waf = require('../waf')
|
|
11
|
+
const downstream = require('../downstream_requests')
|
|
12
|
+
const { updateRaspRuleMatchMetricTags } = require('../telemetry')
|
|
8
13
|
const { RULE_TYPES, handleResult } = require('./utils')
|
|
9
14
|
|
|
10
15
|
let config
|
|
11
16
|
|
|
12
17
|
function enable (_config) {
|
|
13
18
|
config = _config
|
|
19
|
+
downstream.enable(_config)
|
|
20
|
+
|
|
14
21
|
httpClientRequestStart.subscribe(analyzeSsrf)
|
|
22
|
+
httpClientResponseFinish.subscribe(handleResponseFinish)
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
function disable () {
|
|
26
|
+
downstream.disable()
|
|
27
|
+
|
|
18
28
|
if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf)
|
|
29
|
+
if (httpClientResponseFinish.hasSubscribers) httpClientResponseFinish.unsubscribe(handleResponseFinish)
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
function analyzeSsrf (ctx) {
|
|
@@ -25,16 +36,67 @@ function analyzeSsrf (ctx) {
|
|
|
25
36
|
|
|
26
37
|
if (!req || !outgoingUrl) return
|
|
27
38
|
|
|
39
|
+
// Determine if we should collect the response body based on sampling rate and redirect URL
|
|
40
|
+
ctx.shouldCollectBody = downstream.shouldSampleBody(req, outgoingUrl)
|
|
41
|
+
|
|
42
|
+
const requestAddresses = downstream.extractRequestData(ctx)
|
|
43
|
+
|
|
28
44
|
const ephemeral = {
|
|
29
45
|
[addresses.HTTP_OUTGOING_URL]: outgoingUrl,
|
|
46
|
+
...requestAddresses,
|
|
30
47
|
}
|
|
31
48
|
|
|
32
|
-
const raspRule = { type: RULE_TYPES.SSRF }
|
|
49
|
+
const raspRule = { type: RULE_TYPES.SSRF, variant: 'request' }
|
|
33
50
|
|
|
34
51
|
const result = waf.run({ ephemeral }, req, raspRule)
|
|
35
52
|
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
handleResult(result, req, store?.res, ctx.abortController, config, raspRule)
|
|
54
|
+
|
|
55
|
+
downstream.incrementDownstreamAnalysisCount(req)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Finalizes body collection for the response and triggers RASP analysis.
|
|
60
|
+
* @param {{
|
|
61
|
+
* ctx: object,
|
|
62
|
+
* res: import('http').IncomingMessage,
|
|
63
|
+
* body: string|Buffer|null
|
|
64
|
+
* }} payload event payload from the channel.
|
|
65
|
+
*/
|
|
66
|
+
function handleResponseFinish ({ ctx, res, body }) {
|
|
67
|
+
// downstream response object
|
|
68
|
+
if (!res) return
|
|
69
|
+
|
|
70
|
+
const store = storage('legacy').getStore()
|
|
71
|
+
const originatingRequest = store?.req
|
|
72
|
+
if (!originatingRequest) return
|
|
73
|
+
|
|
74
|
+
// Skip body analysis for redirect responses
|
|
75
|
+
const evaluateBody = ctx.shouldCollectBody && !downstream.handleRedirectResponse(originatingRequest, res)
|
|
76
|
+
const responseBody = evaluateBody ? body : null
|
|
77
|
+
runResponseEvaluation(res, originatingRequest, responseBody)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Evaluates the downstream response and records telemetry.
|
|
82
|
+
* @param {import('http').IncomingMessage} res incoming response from downstream service.
|
|
83
|
+
* @param {import('http').IncomingMessage} req originating request.
|
|
84
|
+
* @param {string|Buffer|null} responseBody collected downstream response body.
|
|
85
|
+
*/
|
|
86
|
+
function runResponseEvaluation (res, req, responseBody) {
|
|
87
|
+
const responseAddresses = downstream.extractResponseData(res, responseBody)
|
|
88
|
+
|
|
89
|
+
if (!Object.keys(responseAddresses).length) return
|
|
90
|
+
|
|
91
|
+
const raspRule = { type: RULE_TYPES.SSRF, variant: 'response' }
|
|
92
|
+
const result = waf.run({ ephemeral: responseAddresses }, req, raspRule)
|
|
93
|
+
|
|
94
|
+
// TODO: this should be done in the waf functions directly instead of calling it everywhere
|
|
95
|
+
const ruleTriggered = !!result?.events?.length
|
|
96
|
+
|
|
97
|
+
if (ruleTriggered) {
|
|
98
|
+
updateRaspRuleMatchMetricTags(req, raspRule, false, false)
|
|
99
|
+
}
|
|
38
100
|
}
|
|
39
101
|
|
|
40
102
|
module.exports = { enable, disable }
|
|
@@ -16,6 +16,12 @@ const session = require('../../../debugger/devtools_client/session')
|
|
|
16
16
|
const { getGeneratedPosition } = require('../../../debugger/devtools_client/source-maps')
|
|
17
17
|
// TODO: move debugger/devtools_client/snapshot to common place
|
|
18
18
|
const { getLocalStateForCallFrame } = require('../../../debugger/devtools_client/snapshot')
|
|
19
|
+
const {
|
|
20
|
+
DEFAULT_MAX_REFERENCE_DEPTH,
|
|
21
|
+
DEFAULT_MAX_COLLECTION_SIZE,
|
|
22
|
+
DEFAULT_MAX_FIELD_COUNT,
|
|
23
|
+
DEFAULT_MAX_LENGTH,
|
|
24
|
+
} = require('../../../debugger/devtools_client/snapshot/constants')
|
|
19
25
|
// TODO: move debugger/devtools_client/state to common place
|
|
20
26
|
const {
|
|
21
27
|
findScriptFromPartialPath,
|
|
@@ -29,6 +35,13 @@ let sessionStarted = false
|
|
|
29
35
|
const breakpointIdToProbe = new Map()
|
|
30
36
|
const probeIdToBreakpointId = new Map()
|
|
31
37
|
|
|
38
|
+
const limits = {
|
|
39
|
+
maxReferenceDepth: DEFAULT_MAX_REFERENCE_DEPTH,
|
|
40
|
+
maxCollectionSize: DEFAULT_MAX_COLLECTION_SIZE,
|
|
41
|
+
maxFieldCount: DEFAULT_MAX_FIELD_COUNT,
|
|
42
|
+
maxLength: DEFAULT_MAX_LENGTH,
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint], callFrames } }) => {
|
|
33
46
|
const probe = breakpointIdToProbe.get(hitBreakpoint)
|
|
34
47
|
if (!probe) {
|
|
@@ -38,7 +51,7 @@ session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint]
|
|
|
38
51
|
|
|
39
52
|
const stack = await getStackFromCallFrames(callFrames)
|
|
40
53
|
|
|
41
|
-
const { processLocalState } = await getLocalStateForCallFrame(callFrames[0])
|
|
54
|
+
const { processLocalState } = await getLocalStateForCallFrame(callFrames[0], limits)
|
|
42
55
|
|
|
43
56
|
await session.post('Debugger.resume')
|
|
44
57
|
|
|
@@ -26,6 +26,8 @@ module.exports = {
|
|
|
26
26
|
'appsec.apiSecurity.sampleDelay': 30,
|
|
27
27
|
'appsec.apiSecurity.endpointCollectionEnabled': true,
|
|
28
28
|
'appsec.apiSecurity.endpointCollectionMessageLimit': 300,
|
|
29
|
+
'appsec.apiSecurity.downstreamBodyAnalysisSampleRate': 0.5,
|
|
30
|
+
'appsec.apiSecurity.maxDownstreamRequestBodyAnalysis': 1,
|
|
29
31
|
'appsec.blockedTemplateGraphql': undefined,
|
|
30
32
|
'appsec.blockedTemplateHtml': undefined,
|
|
31
33
|
'appsec.blockedTemplateJson': undefined,
|
|
@@ -255,6 +255,8 @@ class Config {
|
|
|
255
255
|
DD_API_SECURITY_SAMPLE_DELAY,
|
|
256
256
|
DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED,
|
|
257
257
|
DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT,
|
|
258
|
+
DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE,
|
|
259
|
+
DD_API_SECURITY_MAX_DOWNSTREAM_REQUEST_BODY_ANALYSIS,
|
|
258
260
|
DD_APM_TRACING_ENABLED,
|
|
259
261
|
DD_APP_KEY,
|
|
260
262
|
DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE,
|
|
@@ -540,6 +542,10 @@ class Config {
|
|
|
540
542
|
unprocessedTarget['appsec.stackTrace.maxStackTraces'] = DD_APPSEC_MAX_STACK_TRACES
|
|
541
543
|
target['appsec.wafTimeout'] = maybeInt(DD_APPSEC_WAF_TIMEOUT)
|
|
542
544
|
unprocessedTarget['appsec.wafTimeout'] = DD_APPSEC_WAF_TIMEOUT
|
|
545
|
+
target['appsec.apiSecurity.downstreamBodyAnalysisSampleRate'] =
|
|
546
|
+
maybeFloat(DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE)
|
|
547
|
+
target['appsec.apiSecurity.maxDownstreamRequestBodyAnalysis'] =
|
|
548
|
+
maybeInt(DD_API_SECURITY_MAX_DOWNSTREAM_REQUEST_BODY_ANALYSIS)
|
|
543
549
|
target.baggageMaxBytes = DD_TRACE_BAGGAGE_MAX_BYTES
|
|
544
550
|
target.baggageMaxItems = DD_TRACE_BAGGAGE_MAX_ITEMS
|
|
545
551
|
target.baggageTagKeys = DD_TRACE_BAGGAGE_TAG_KEYS
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"DD_API_SECURITY_SAMPLE_DELAY": ["A"],
|
|
16
16
|
"DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED": ["A"],
|
|
17
17
|
"DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT": ["A"],
|
|
18
|
+
"DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE": ["A"],
|
|
19
|
+
"DD_API_SECURITY_MAX_DOWNSTREAM_REQUEST_BODY_ANALYSIS": ["A"],
|
|
18
20
|
"DD_APM_FLUSH_DEADLINE_MILLISECONDS": ["A"],
|
|
19
21
|
"DD_APM_TRACING_ENABLED": ["A"],
|
|
20
22
|
"DD_APP_KEY": ["A"],
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
const mutex = require('../../../../../vendor/dist/mutexify/promise')()
|
|
4
4
|
const { getGeneratedPosition } = require('./source-maps')
|
|
5
5
|
const session = require('./session')
|
|
6
|
-
const { compile
|
|
6
|
+
const { compile, compileSegments, templateRequiresEvaluation } = require('./condition')
|
|
7
7
|
const { MAX_SNAPSHOTS_PER_SECOND_PER_PROBE, MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE } = require('./defaults')
|
|
8
|
+
const {
|
|
9
|
+
DEFAULT_MAX_REFERENCE_DEPTH,
|
|
10
|
+
DEFAULT_MAX_COLLECTION_SIZE,
|
|
11
|
+
DEFAULT_MAX_FIELD_COUNT,
|
|
12
|
+
DEFAULT_MAX_LENGTH,
|
|
13
|
+
} = require('./snapshot/constants')
|
|
8
14
|
const {
|
|
9
15
|
findScriptFromPartialPath,
|
|
10
16
|
clearState,
|
|
@@ -99,7 +105,7 @@ async function addBreakpoint (probe) {
|
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
try {
|
|
102
|
-
probe.condition = probe.when?.json &&
|
|
108
|
+
probe.condition = probe.when?.json && compile(probe.when.json)
|
|
103
109
|
} catch (err) {
|
|
104
110
|
throw new Error(
|
|
105
111
|
`Cannot compile expression: ${probe.when.dsl} (probe: ${probe.id}, version: ${probe.version})`,
|
|
@@ -107,6 +113,45 @@ async function addBreakpoint (probe) {
|
|
|
107
113
|
)
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
if (probe.captureSnapshot) {
|
|
117
|
+
probe.capture = {
|
|
118
|
+
maxReferenceDepth: probe.capture?.maxReferenceDepth ?? DEFAULT_MAX_REFERENCE_DEPTH,
|
|
119
|
+
maxCollectionSize: probe.capture?.maxCollectionSize ?? DEFAULT_MAX_COLLECTION_SIZE,
|
|
120
|
+
maxFieldCount: probe.capture?.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
|
|
121
|
+
maxLength: probe.capture?.maxLength ?? DEFAULT_MAX_LENGTH,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (probe.captureExpressions?.length > 0) {
|
|
126
|
+
probe.compiledCaptureExpressions = []
|
|
127
|
+
for (const captureExpr of probe.captureExpressions) {
|
|
128
|
+
let expression
|
|
129
|
+
try {
|
|
130
|
+
expression = compile(captureExpr.expr.json)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Cannot compile capture expression: ${captureExpr.name} (probe: ${probe.id}, version: ${probe.version})`,
|
|
134
|
+
{ cause: err }
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
probe.compiledCaptureExpressions.push({
|
|
139
|
+
name: captureExpr.name,
|
|
140
|
+
expression,
|
|
141
|
+
limits: {
|
|
142
|
+
maxReferenceDepth: captureExpr.capture?.maxReferenceDepth ??
|
|
143
|
+
probe.capture?.maxReferenceDepth ?? DEFAULT_MAX_REFERENCE_DEPTH,
|
|
144
|
+
maxCollectionSize: captureExpr.capture?.maxCollectionSize ??
|
|
145
|
+
probe.capture?.maxCollectionSize ?? DEFAULT_MAX_COLLECTION_SIZE,
|
|
146
|
+
maxFieldCount: captureExpr.capture?.maxFieldCount ??
|
|
147
|
+
probe.capture?.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
|
|
148
|
+
maxLength: captureExpr.capture?.maxLength ??
|
|
149
|
+
probe.capture?.maxLength ?? DEFAULT_MAX_LENGTH,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
110
155
|
const locationKey = generateLocationKey(scriptId, lineNumber, columnNumber)
|
|
111
156
|
const breakpoint = locationToBreakpoint.get(locationKey)
|
|
112
157
|
|