dd-trace 4.40.0 → 4.42.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.
Files changed (74) hide show
  1. package/LICENSE-3rdparty.csv +1 -0
  2. package/ext/exporters.d.ts +1 -1
  3. package/index.d.ts +54 -1
  4. package/init.js +40 -1
  5. package/initialize.mjs +8 -5
  6. package/package.json +24 -20
  7. package/packages/datadog-core/src/storage/index.js +1 -10
  8. package/packages/datadog-esbuild/index.js +5 -1
  9. package/packages/datadog-instrumentations/src/aws-sdk.js +2 -1
  10. package/packages/datadog-instrumentations/src/cucumber.js +76 -34
  11. package/packages/datadog-instrumentations/src/helpers/hook.js +8 -3
  12. package/packages/datadog-instrumentations/src/helpers/hooks.js +3 -0
  13. package/packages/datadog-instrumentations/src/helpers/instrument.js +4 -3
  14. package/packages/datadog-instrumentations/src/helpers/register.js +56 -5
  15. package/packages/datadog-instrumentations/src/http/server.js +98 -0
  16. package/packages/datadog-instrumentations/src/mocha/main.js +12 -1
  17. package/packages/datadog-instrumentations/src/mocha/utils.js +58 -14
  18. package/packages/datadog-instrumentations/src/mocha/worker.js +1 -0
  19. package/packages/datadog-instrumentations/src/playwright.js +1 -1
  20. package/packages/datadog-instrumentations/src/undici.js +18 -0
  21. package/packages/datadog-instrumentations/src/vitest.js +303 -0
  22. package/packages/datadog-plugin-aws-sdk/src/base.js +8 -1
  23. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +9 -3
  24. package/packages/datadog-plugin-aws-sdk/src/services/sns.js +6 -1
  25. package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +23 -5
  26. package/packages/datadog-plugin-child_process/src/index.js +1 -1
  27. package/packages/datadog-plugin-cucumber/src/index.js +24 -1
  28. package/packages/datadog-plugin-mocha/src/index.js +25 -4
  29. package/packages/datadog-plugin-openai/src/index.js +52 -30
  30. package/packages/datadog-plugin-openai/src/token-estimator.js +20 -0
  31. package/packages/datadog-plugin-undici/src/index.js +12 -0
  32. package/packages/datadog-plugin-vitest/src/index.js +156 -0
  33. package/packages/dd-trace/src/appsec/blocking.js +4 -0
  34. package/packages/dd-trace/src/appsec/channels.js +1 -0
  35. package/packages/dd-trace/src/appsec/iast/path-line.js +2 -19
  36. package/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +4 -0
  37. package/packages/dd-trace/src/appsec/index.js +45 -11
  38. package/packages/dd-trace/src/appsec/rasp.js +32 -5
  39. package/packages/dd-trace/src/appsec/recommended.json +208 -3
  40. package/packages/dd-trace/src/appsec/remote_config/capabilities.js +1 -0
  41. package/packages/dd-trace/src/appsec/remote_config/index.js +2 -0
  42. package/packages/dd-trace/src/appsec/reporter.js +64 -20
  43. package/packages/dd-trace/src/appsec/sdk/track_event.js +3 -0
  44. package/packages/dd-trace/src/appsec/stack_trace.js +90 -0
  45. package/packages/dd-trace/src/appsec/standalone.js +130 -0
  46. package/packages/dd-trace/src/appsec/telemetry.js +33 -1
  47. package/packages/dd-trace/src/appsec/waf/index.js +2 -2
  48. package/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js +2 -2
  49. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +4 -2
  50. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  51. package/packages/dd-trace/src/config.js +110 -40
  52. package/packages/dd-trace/src/constants.js +3 -1
  53. package/packages/dd-trace/src/datastreams/processor.js +2 -1
  54. package/packages/dd-trace/src/exporters/agent/index.js +2 -2
  55. package/packages/dd-trace/src/format.js +22 -2
  56. package/packages/dd-trace/src/opentelemetry/span.js +33 -7
  57. package/packages/dd-trace/src/opentracing/propagation/text_map.js +12 -0
  58. package/packages/dd-trace/src/opentracing/span.js +42 -1
  59. package/packages/dd-trace/src/opentracing/tracer.js +2 -2
  60. package/packages/dd-trace/src/plugins/ci_plugin.js +7 -0
  61. package/packages/dd-trace/src/plugins/index.js +3 -0
  62. package/packages/dd-trace/src/plugins/util/test.js +5 -1
  63. package/packages/dd-trace/src/priority_sampler.js +2 -5
  64. package/packages/dd-trace/src/profiling/profiler.js +1 -1
  65. package/packages/dd-trace/src/proxy.js +3 -1
  66. package/packages/dd-trace/src/rate_limiter.js +2 -2
  67. package/packages/dd-trace/src/service-naming/schemas/v0/web.js +4 -0
  68. package/packages/dd-trace/src/service-naming/schemas/v1/web.js +4 -0
  69. package/packages/dd-trace/src/span_stats.js +4 -3
  70. package/packages/dd-trace/src/tagger.js +10 -1
  71. package/packages/dd-trace/src/telemetry/init-telemetry.js +75 -0
  72. package/packages/dd-trace/src/tracer.js +2 -2
  73. package/packages/dd-trace/src/util.js +6 -1
  74. package/packages/datadog-core/src/storage/async_hooks.js +0 -49
@@ -7,6 +7,7 @@ const { storage } = require('../../datadog-core')
7
7
  const services = require('./services')
8
8
  const Sampler = require('../../dd-trace/src/sampler')
9
9
  const { MEASURED } = require('../../../ext/tags')
10
+ const { estimateTokens } = require('./token-estimator')
10
11
 
11
12
  // String#replaceAll unavailable on Node.js@v14 (dd-trace@<=v3)
12
13
  const RE_NEWLINE = /\n/g
@@ -15,14 +16,17 @@ const RE_TAB = /\t/g
15
16
  // TODO: In the future we should refactor config.js to make it requirable
16
17
  let MAX_TEXT_LEN = 128
17
18
 
18
- let encodingForModel
19
- try {
20
- // eslint-disable-next-line import/no-extraneous-dependencies
21
- encodingForModel = require('tiktoken').encoding_for_model
22
- } catch {
23
- // we will use token count estimations in this case
19
+ function safeRequire (path) {
20
+ try {
21
+ // eslint-disable-next-line import/no-extraneous-dependencies
22
+ return require(path)
23
+ } catch {
24
+ return null
25
+ }
24
26
  }
25
27
 
28
+ const encodingForModel = safeRequire('tiktoken')?.encoding_for_model
29
+
26
30
  class OpenApiPlugin extends TracingPlugin {
27
31
  static get id () { return 'openai' }
28
32
  static get operation () { return 'request' }
@@ -305,6 +309,7 @@ class OpenApiPlugin extends TracingPlugin {
305
309
  }
306
310
 
307
311
  sendLog (methodName, span, tags, store, error) {
312
+ if (!store) return
308
313
  if (!Object.keys(store).length) return
309
314
  if (!this.sampler.isSampled()) return
310
315
 
@@ -325,9 +330,22 @@ function countPromptTokens (methodName, payload, model) {
325
330
  const messages = payload.messages
326
331
  for (const message of messages) {
327
332
  const content = message.content
328
- const { tokens, estimated } = countTokens(content, model)
329
- promptTokens += tokens
330
- promptEstimated = estimated
333
+ if (typeof content === 'string') {
334
+ const { tokens, estimated } = countTokens(content, model)
335
+ promptTokens += tokens
336
+ promptEstimated = estimated
337
+ } else if (Array.isArray(content)) {
338
+ for (const c of content) {
339
+ if (c.type === 'text') {
340
+ const { tokens, estimated } = countTokens(c.text, model)
341
+ promptTokens += tokens
342
+ promptEstimated = estimated
343
+ }
344
+ // unsupported token computation for image_url
345
+ // as even though URL is a string, its true token count
346
+ // is based on the image itself, something onerous to do client-side
347
+ }
348
+ }
331
349
  }
332
350
  } else if (methodName === 'completions.create') {
333
351
  let prompt = payload.prompt
@@ -382,25 +400,6 @@ function countTokens (content, model) {
382
400
  }
383
401
  }
384
402
 
385
- // If model is unavailable or tiktoken is not imported, then provide a very rough estimate of the number of tokens
386
- // Approximate using the following assumptions:
387
- // * English text
388
- // * 1 token ~= 4 chars
389
- // * 1 token ~= ¾ words
390
- function estimateTokens (content) {
391
- let estimatedTokens = 0
392
- if (typeof content === 'string') {
393
- const estimation1 = content.length / 4
394
-
395
- const matches = content.match(/[\w']+|[.,!?;~@#$%^&*()+/-]/g)
396
- const estimation2 = matches ? matches.length * 0.75 : 0 // in the case of an empty string
397
- estimatedTokens = Math.round((1.5 * estimation1 + 0.5 * estimation2) / 2)
398
- } else if (Array.isArray(content) && typeof content[0] === 'number') {
399
- estimatedTokens = content.length
400
- }
401
- return estimatedTokens
402
- }
403
-
404
403
  function createEditRequestExtraction (tags, payload, store) {
405
404
  const instruction = payload.instruction
406
405
  tags['openai.request.instruction'] = instruction
@@ -418,7 +417,7 @@ function createChatCompletionRequestExtraction (tags, payload, store) {
418
417
  store.messages = payload.messages
419
418
  for (let i = 0; i < payload.messages.length; i++) {
420
419
  const message = payload.messages[i]
421
- tags[`openai.request.messages.${i}.content`] = truncateText(message.content)
420
+ tagChatCompletionRequestContent(message.content, i, tags)
422
421
  tags[`openai.request.messages.${i}.role`] = message.role
423
422
  tags[`openai.request.messages.${i}.name`] = message.name
424
423
  tags[`openai.request.messages.${i}.finish_reason`] = message.finish_reason
@@ -707,7 +706,7 @@ function commonCreateResponseExtraction (tags, body, store, methodName) {
707
706
  for (let choiceIdx = 0; choiceIdx < body.choices.length; choiceIdx++) {
708
707
  const choice = body.choices[choiceIdx]
709
708
 
710
- // logprobs can be nullm and we still want to tag it as 'returned' even when set to 'null'
709
+ // logprobs can be null and we still want to tag it as 'returned' even when set to 'null'
711
710
  const specifiesLogProb = Object.keys(choice).indexOf('logprobs') !== -1
712
711
 
713
712
  tags[`openai.response.choices.${choiceIdx}.finish_reason`] = choice.finish_reason
@@ -781,6 +780,7 @@ function truncateApiKey (apiKey) {
781
780
  */
782
781
  function truncateText (text) {
783
782
  if (!text) return
783
+ if (typeof text !== 'string' || !text || (typeof text === 'string' && text.length === 0)) return
784
784
 
785
785
  text = text
786
786
  .replace(RE_NEWLINE, '\\n')
@@ -793,6 +793,28 @@ function truncateText (text) {
793
793
  return text
794
794
  }
795
795
 
796
+ function tagChatCompletionRequestContent (contents, messageIdx, tags) {
797
+ if (typeof contents === 'string') {
798
+ tags[`openai.request.messages.${messageIdx}.content`] = contents
799
+ } else if (Array.isArray(contents)) {
800
+ // content can also be an array of objects
801
+ // which represent text input or image url
802
+ for (const contentIdx in contents) {
803
+ const content = contents[contentIdx]
804
+ const type = content.type
805
+ tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.type`] = content.type
806
+ if (type === 'text') {
807
+ tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.text`] = truncateText(content.text)
808
+ } else if (type === 'image_url') {
809
+ tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.image_url.url`] =
810
+ truncateText(content.image_url.url)
811
+ }
812
+ // unsupported type otherwise, won't be tagged
813
+ }
814
+ }
815
+ // unsupported type otherwise, won't be tagged
816
+ }
817
+
796
818
  // The server almost always responds with JSON
797
819
  function coerceResponseBody (body, methodName) {
798
820
  switch (methodName) {
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ // If model is unavailable or tiktoken is not imported, then provide a very rough estimate of the number of tokens
4
+ // Approximate using the following assumptions:
5
+ // * English text
6
+ // * 1 token ~= 4 chars
7
+ // * 1 token ~= ¾ words
8
+ module.exports.estimateTokens = function (content) {
9
+ let estimatedTokens = 0
10
+ if (typeof content === 'string') {
11
+ const estimation1 = content.length / 4
12
+
13
+ const matches = content.match(/[\w']+|[.,!?;~@#$%^&*()+/-]/g)
14
+ const estimation2 = matches ? matches.length * 0.75 : 0 // in the case of an empty string
15
+ estimatedTokens = Math.round((1.5 * estimation1 + 0.5 * estimation2) / 2)
16
+ } else if (Array.isArray(content) && typeof content[0] === 'number') {
17
+ estimatedTokens = content.length
18
+ }
19
+ return estimatedTokens
20
+ }
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ const FetchPlugin = require('../../datadog-plugin-fetch/src/index.js')
4
+
5
+ class UndiciPlugin extends FetchPlugin {
6
+ static get id () { return 'undici' }
7
+ static get prefix () {
8
+ return 'tracing:apm:undici:fetch'
9
+ }
10
+ }
11
+
12
+ module.exports = UndiciPlugin
@@ -0,0 +1,156 @@
1
+ const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
2
+ const { storage } = require('../../datadog-core')
3
+
4
+ const {
5
+ TEST_STATUS,
6
+ finishAllTraceSpans,
7
+ getTestSuitePath,
8
+ getTestSuiteCommonTags,
9
+ TEST_SOURCE_FILE
10
+ } = require('../../dd-trace/src/plugins/util/test')
11
+ const { COMPONENT } = require('../../dd-trace/src/constants')
12
+
13
+ // Milliseconds that we subtract from the error test duration
14
+ // so that they do not overlap with the following test
15
+ // This is because there's some loss of resolution.
16
+ const MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION = 5
17
+
18
+ class VitestPlugin extends CiPlugin {
19
+ static get id () {
20
+ return 'vitest'
21
+ }
22
+
23
+ constructor (...args) {
24
+ super(...args)
25
+
26
+ this.taskToFinishTime = new WeakMap()
27
+
28
+ this.addSub('ci:vitest:test:start', ({ testName, testSuiteAbsolutePath }) => {
29
+ const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
30
+ const store = storage.getStore()
31
+ const span = this.startTestSpan(
32
+ testName,
33
+ testSuite,
34
+ this.testSuiteSpan,
35
+ {
36
+ [TEST_SOURCE_FILE]: testSuite
37
+ }
38
+ )
39
+
40
+ this.enter(span, store)
41
+ })
42
+
43
+ this.addSub('ci:vitest:test:finish-time', ({ status, task }) => {
44
+ const store = storage.getStore()
45
+ const span = store?.span
46
+
47
+ // we store the finish time to finish at a later hook
48
+ // this is because the test might fail at a `afterEach` hook
49
+ if (span) {
50
+ span.setTag(TEST_STATUS, status)
51
+ this.taskToFinishTime.set(task, span._getTime())
52
+ }
53
+ })
54
+
55
+ this.addSub('ci:vitest:test:pass', ({ task }) => {
56
+ const store = storage.getStore()
57
+ const span = store?.span
58
+
59
+ if (span) {
60
+ span.setTag(TEST_STATUS, 'pass')
61
+ span.finish(this.taskToFinishTime.get(task))
62
+ finishAllTraceSpans(span)
63
+ }
64
+ })
65
+
66
+ this.addSub('ci:vitest:test:error', ({ duration, error }) => {
67
+ const store = storage.getStore()
68
+ const span = store?.span
69
+
70
+ if (span) {
71
+ span.setTag(TEST_STATUS, 'fail')
72
+
73
+ if (error) {
74
+ span.setTag('error', error)
75
+ }
76
+ span.finish(span._startTime + duration - MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION) // milliseconds
77
+ finishAllTraceSpans(span)
78
+ }
79
+ })
80
+
81
+ this.addSub('ci:vitest:test:skip', ({ testName, testSuiteAbsolutePath }) => {
82
+ const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
83
+ this.startTestSpan(
84
+ testName,
85
+ testSuite,
86
+ this.testSuiteSpan,
87
+ {
88
+ [TEST_SOURCE_FILE]: testSuite,
89
+ [TEST_STATUS]: 'skip'
90
+ }
91
+ ).finish()
92
+ })
93
+
94
+ this.addSub('ci:vitest:test-suite:start', (testSuiteAbsolutePath) => {
95
+ const testSessionSpanContext = this.tracer.extract('text_map', {
96
+ 'x-datadog-trace-id': process.env.DD_CIVISIBILITY_TEST_SESSION_ID,
97
+ 'x-datadog-parent-id': process.env.DD_CIVISIBILITY_TEST_MODULE_ID
98
+ })
99
+
100
+ const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot)
101
+ const testSuiteMetadata = getTestSuiteCommonTags(
102
+ this.command,
103
+ this.frameworkVersion,
104
+ testSuite,
105
+ 'vitest'
106
+ )
107
+ const testSuiteSpan = this.tracer.startSpan('vitest.test_suite', {
108
+ childOf: testSessionSpanContext,
109
+ tags: {
110
+ [COMPONENT]: this.constructor.id,
111
+ ...this.testEnvironmentMetadata,
112
+ ...testSuiteMetadata
113
+ }
114
+ })
115
+ const store = storage.getStore()
116
+ this.enter(testSuiteSpan, store)
117
+ this.testSuiteSpan = testSuiteSpan
118
+ })
119
+
120
+ this.addSub('ci:vitest:test-suite:finish', ({ status, onFinish }) => {
121
+ const store = storage.getStore()
122
+ const span = store?.span
123
+ if (span) {
124
+ span.setTag(TEST_STATUS, status)
125
+ span.finish()
126
+ finishAllTraceSpans(span)
127
+ }
128
+ // TODO: too frequent flush - find for method in worker to decrease frequency
129
+ this.tracer._exporter.flush(onFinish)
130
+ })
131
+
132
+ this.addSub('ci:vitest:test-suite:error', ({ error }) => {
133
+ const store = storage.getStore()
134
+ const span = store?.span
135
+ if (span && error) {
136
+ span.setTag('error', error)
137
+ span.setTag(TEST_STATUS, 'fail')
138
+ }
139
+ })
140
+
141
+ this.addSub('ci:vitest:session:finish', ({ status, onFinish, error }) => {
142
+ this.testSessionSpan.setTag(TEST_STATUS, status)
143
+ this.testModuleSpan.setTag(TEST_STATUS, status)
144
+ if (error) {
145
+ this.testModuleSpan.setTag('error', error)
146
+ this.testSessionSpan.setTag('error', error)
147
+ }
148
+ this.testModuleSpan.finish()
149
+ this.testSessionSpan.finish()
150
+ finishAllTraceSpans(this.testSessionSpan)
151
+ this.tracer._exporter.flush(onFinish)
152
+ })
153
+ }
154
+ }
155
+
156
+ module.exports = VitestPlugin
@@ -111,6 +111,10 @@ function block (req, res, rootSpan, abortController, actionParameters) {
111
111
 
112
112
  const { body, headers, statusCode } = getBlockingData(req, null, rootSpan, actionParameters)
113
113
 
114
+ for (const headerName of res.getHeaderNames()) {
115
+ res.removeHeader(headerName)
116
+ }
117
+
114
118
  res.writeHead(statusCode, headers).end(body)
115
119
 
116
120
  abortController?.abort()
@@ -18,5 +18,6 @@ module.exports = {
18
18
  nextBodyParsed: dc.channel('apm:next:body-parsed'),
19
19
  nextQueryParsed: dc.channel('apm:next:query-parsed'),
20
20
  responseBody: dc.channel('datadog:express:response:json:start'),
21
+ responseWriteHead: dc.channel('apm:http:server:response:writeHead:start'),
21
22
  httpClientRequestStart: dc.channel('apm:http:client:request:start')
22
23
  }
@@ -3,6 +3,7 @@
3
3
  const path = require('path')
4
4
  const process = require('process')
5
5
  const { calculateDDBasePath } = require('../../util')
6
+ const { getCallSiteList } = require('../stack_trace')
6
7
  const pathLine = {
7
8
  getFirstNonDDPathAndLine,
8
9
  getNodeModulesPaths,
@@ -24,24 +25,6 @@ const EXCLUDED_PATH_PREFIXES = [
24
25
  'async_hooks'
25
26
  ]
26
27
 
27
- function getCallSiteInfo () {
28
- const previousPrepareStackTrace = Error.prepareStackTrace
29
- const previousStackTraceLimit = Error.stackTraceLimit
30
- let callsiteList
31
- Error.stackTraceLimit = 100
32
- try {
33
- Error.prepareStackTrace = function (_, callsites) {
34
- callsiteList = callsites
35
- }
36
- const e = new Error()
37
- e.stack
38
- } finally {
39
- Error.prepareStackTrace = previousPrepareStackTrace
40
- Error.stackTraceLimit = previousStackTraceLimit
41
- }
42
- return callsiteList
43
- }
44
-
45
28
  function getFirstNonDDPathAndLineFromCallsites (callsites, externallyExcludedPaths) {
46
29
  if (callsites) {
47
30
  for (let i = 0; i < callsites.length; i++) {
@@ -91,7 +74,7 @@ function isExcluded (callsite, externallyExcludedPaths) {
91
74
  }
92
75
 
93
76
  function getFirstNonDDPathAndLine (externallyExcludedPaths) {
94
- return getFirstNonDDPathAndLineFromCallsites(getCallSiteInfo(), externallyExcludedPaths)
77
+ return getFirstNonDDPathAndLineFromCallsites(getCallSiteList(), externallyExcludedPaths)
95
78
  }
96
79
 
97
80
  function getNodeModulesPaths (...paths) {
@@ -4,6 +4,7 @@ const { MANUAL_KEEP } = require('../../../../../ext/tags')
4
4
  const LRU = require('lru-cache')
5
5
  const vulnerabilitiesFormatter = require('./vulnerabilities-formatter')
6
6
  const { IAST_ENABLED_TAG_KEY, IAST_JSON_TAG_KEY } = require('./tags')
7
+ const standalone = require('../standalone')
7
8
 
8
9
  const VULNERABILITIES_KEY = 'vulnerabilities'
9
10
  const VULNERABILITY_HASHES_MAX_SIZE = 1000
@@ -57,6 +58,9 @@ function sendVulnerabilities (vulnerabilities, rootSpan) {
57
58
  tags[IAST_JSON_TAG_KEY] = JSON.stringify(jsonToSend)
58
59
  tags[MANUAL_KEEP] = 'true'
59
60
  span.addTags(tags)
61
+
62
+ standalone.sample(span)
63
+
60
64
  if (!rootSpan) span.finish()
61
65
  }
62
66
  }
@@ -12,7 +12,8 @@ const {
12
12
  queryParser,
13
13
  nextBodyParsed,
14
14
  nextQueryParsed,
15
- responseBody
15
+ responseBody,
16
+ responseWriteHead
16
17
  } = require('./channels')
17
18
  const waf = require('./waf')
18
19
  const addresses = require('./addresses')
@@ -39,7 +40,7 @@ function enable (_config) {
39
40
  graphql.enable()
40
41
 
41
42
  if (_config.appsec.rasp.enabled) {
42
- rasp.enable()
43
+ rasp.enable(_config)
43
44
  }
44
45
 
45
46
  setTemplates(_config)
@@ -60,6 +61,7 @@ function enable (_config) {
60
61
  queryParser.subscribe(onRequestQueryParsed)
61
62
  cookieParser.subscribe(onRequestCookieParser)
62
63
  responseBody.subscribe(onResponseBody)
64
+ responseWriteHead.subscribe(onResponseWriteHead)
63
65
 
64
66
  if (_config.appsec.eventTracking.enabled) {
65
67
  passportVerify.subscribe(onPassportVerify)
@@ -110,14 +112,7 @@ function incomingHttpStartTranslator ({ req, res, abortController }) {
110
112
  }
111
113
 
112
114
  function incomingHttpEndTranslator ({ req, res }) {
113
- // TODO: this doesn't support headers sent with res.writeHead()
114
- const responseHeaders = Object.assign({}, res.getHeaders())
115
- delete responseHeaders['set-cookie']
116
-
117
- const persistent = {
118
- [addresses.HTTP_INCOMING_RESPONSE_CODE]: '' + res.statusCode,
119
- [addresses.HTTP_INCOMING_RESPONSE_HEADERS]: responseHeaders
120
- }
115
+ const persistent = {}
121
116
 
122
117
  // we need to keep this to support other body parsers
123
118
  // TODO: no need to analyze it if it was already done by the body-parser hook
@@ -139,7 +134,9 @@ function incomingHttpEndTranslator ({ req, res }) {
139
134
  persistent[addresses.HTTP_INCOMING_QUERY] = req.query
140
135
  }
141
136
 
142
- waf.run({ persistent }, req)
137
+ if (Object.keys(persistent).length) {
138
+ waf.run({ persistent }, req)
139
+ }
143
140
 
144
141
  waf.disposeContext(req)
145
142
 
@@ -225,12 +222,48 @@ function onPassportVerify ({ credentials, user }) {
225
222
  passportTrackEvent(credentials, user, rootSpan, config.appsec.eventTracking.mode)
226
223
  }
227
224
 
225
+ const responseAnalyzedSet = new WeakSet()
226
+ const responseBlockedSet = new WeakSet()
227
+
228
+ function onResponseWriteHead ({ req, res, abortController, statusCode, responseHeaders }) {
229
+ // avoid "write after end" error
230
+ if (responseBlockedSet.has(res)) {
231
+ abortController?.abort()
232
+ return
233
+ }
234
+
235
+ // avoid double waf call
236
+ if (responseAnalyzedSet.has(res)) {
237
+ return
238
+ }
239
+
240
+ const rootSpan = web.root(req)
241
+ if (!rootSpan) return
242
+
243
+ responseHeaders = Object.assign({}, responseHeaders)
244
+ delete responseHeaders['set-cookie']
245
+
246
+ const results = waf.run({
247
+ persistent: {
248
+ [addresses.HTTP_INCOMING_RESPONSE_CODE]: '' + statusCode,
249
+ [addresses.HTTP_INCOMING_RESPONSE_HEADERS]: responseHeaders
250
+ }
251
+ }, req)
252
+
253
+ responseAnalyzedSet.add(res)
254
+
255
+ handleResults(results, req, res, rootSpan, abortController)
256
+ }
257
+
228
258
  function handleResults (actions, req, res, rootSpan, abortController) {
229
259
  if (!actions || !req || !res || !rootSpan || !abortController) return
230
260
 
231
261
  const blockingAction = getBlockingAction(actions)
232
262
  if (blockingAction) {
233
263
  block(req, res, rootSpan, abortController, blockingAction)
264
+ if (!abortController.signal || abortController.signal.aborted) {
265
+ responseBlockedSet.add(res)
266
+ }
234
267
  }
235
268
  }
236
269
 
@@ -256,6 +289,7 @@ function disable () {
256
289
  if (cookieParser.hasSubscribers) cookieParser.unsubscribe(onRequestCookieParser)
257
290
  if (responseBody.hasSubscribers) responseBody.unsubscribe(onResponseBody)
258
291
  if (passportVerify.hasSubscribers) passportVerify.unsubscribe(onPassportVerify)
292
+ if (responseWriteHead.hasSubscribers) responseWriteHead.unsubscribe(onResponseWriteHead)
259
293
  }
260
294
 
261
295
  module.exports = {
@@ -1,11 +1,20 @@
1
1
  'use strict'
2
2
 
3
3
  const { storage } = require('../../../datadog-core')
4
+ const web = require('./../plugins/util/web')
4
5
  const addresses = require('./addresses')
5
6
  const { httpClientRequestStart } = require('./channels')
7
+ const { reportStackTrace } = require('./stack_trace')
6
8
  const waf = require('./waf')
7
9
 
8
- function enable () {
10
+ const RULE_TYPES = {
11
+ SSRF: 'ssrf'
12
+ }
13
+
14
+ let config
15
+
16
+ function enable (_config) {
17
+ config = _config
9
18
  httpClientRequestStart.subscribe(analyzeSsrf)
10
19
  }
11
20
 
@@ -24,12 +33,30 @@ function analyzeSsrf (ctx) {
24
33
  [addresses.HTTP_OUTGOING_URL]: url
25
34
  }
26
35
  // TODO: Currently this is only monitoring, we should
27
- // block the request if SSRF attempt and
28
- // generate stack traces
29
- waf.run({ persistent }, req)
36
+ // block the request if SSRF attempt
37
+ const result = waf.run({ persistent }, req, RULE_TYPES.SSRF)
38
+ handleResult(result, req)
39
+ }
40
+
41
+ function getGenerateStackTraceAction (actions) {
42
+ return actions?.generate_stack
43
+ }
44
+
45
+ function handleResult (actions, req) {
46
+ const generateStackTraceAction = getGenerateStackTraceAction(actions)
47
+ if (generateStackTraceAction && config.appsec.stackTrace.enabled) {
48
+ const rootSpan = web.root(req)
49
+ reportStackTrace(
50
+ rootSpan,
51
+ generateStackTraceAction.stack_id,
52
+ config.appsec.stackTrace.maxDepth,
53
+ config.appsec.stackTrace.maxStackTraces
54
+ )
55
+ }
30
56
  }
31
57
 
32
58
  module.exports = {
33
59
  enable,
34
- disable
60
+ disable,
61
+ handleResult
35
62
  }