Skip to content

Commit 5be6314

Browse files
CarlesDDuurien
andauthored
Add support to generate_stack action (#4382)
* Stack trace collection configuration * Collect and report stack trace for appsec events * Handle generate_stack waf action * Fix linting in config.spec.js * Add assertion for stack trace tag in meta_struct for express test * Refactor reportStackTrace and some additional test * Fix lint * Additional assert in reportStackTrace test * Update config * Rework on stack trace collection * Callsite line and column as numbers * Update packages/dd-trace/src/appsec/stack_trace.js Co-authored-by: Ugaitz Urien <[email protected]> * Update packages/dd-trace/src/appsec/stack_trace.js Co-authored-by: Ugaitz Urien <[email protected]> * Reorder test structure * Fix linting * No exploit stack limit when max is set to 0 or below * Fix filtered and capped frames case * Fix lint --------- Co-authored-by: Ugaitz Urien <[email protected]>
1 parent 1cd017b commit 5be6314

File tree

13 files changed

+601
-29
lines changed

13 files changed

+601
-29
lines changed

docs/test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ tracer.init({
119119
},
120120
rasp: {
121121
enabled: true
122+
},
123+
stackTrace: {
124+
enabled: true,
125+
maxStackTraces: 5,
126+
maxDepth: 42
122127
}
123128
}
124129
});

index.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,25 @@ declare namespace tracer {
700700
* @default false
701701
*/
702702
enabled?: boolean
703+
},
704+
/**
705+
* Configuration for stack trace reporting
706+
*/
707+
stackTrace?: {
708+
/** Whether to enable stack trace reporting.
709+
* @default true
710+
*/
711+
enabled?: boolean,
712+
713+
/** Specifies the maximum number of stack traces to be reported.
714+
* @default 2
715+
*/
716+
maxStackTraces?: number,
717+
718+
/** Specifies the maximum depth of a stack trace to be reported.
719+
* @default 32
720+
*/
721+
maxDepth?: number,
703722
}
704723
};
705724

packages/dd-trace/src/appsec/iast/path-line.js

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const path = require('path')
44
const process = require('process')
55
const { calculateDDBasePath } = require('../../util')
6+
const { getCallSiteList } = require('../stack_trace')
67
const pathLine = {
78
getFirstNonDDPathAndLine,
89
getNodeModulesPaths,
@@ -24,24 +25,6 @@ const EXCLUDED_PATH_PREFIXES = [
2425
'async_hooks'
2526
]
2627

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-
4528
function getFirstNonDDPathAndLineFromCallsites (callsites, externallyExcludedPaths) {
4629
if (callsites) {
4730
for (let i = 0; i < callsites.length; i++) {
@@ -91,7 +74,7 @@ function isExcluded (callsite, externallyExcludedPaths) {
9174
}
9275

9376
function getFirstNonDDPathAndLine (externallyExcludedPaths) {
94-
return getFirstNonDDPathAndLineFromCallsites(getCallSiteInfo(), externallyExcludedPaths)
77+
return getFirstNonDDPathAndLineFromCallsites(getCallSiteList(), externallyExcludedPaths)
9578
}
9679

9780
function getNodeModulesPaths (...paths) {

packages/dd-trace/src/appsec/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function enable (_config) {
4040
graphql.enable()
4141

4242
if (_config.appsec.rasp.enabled) {
43-
rasp.enable()
43+
rasp.enable(_config)
4444
}
4545

4646
setTemplates(_config)

packages/dd-trace/src/appsec/rasp.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
'use strict'
22

33
const { storage } = require('../../../datadog-core')
4+
const web = require('./../plugins/util/web')
45
const addresses = require('./addresses')
56
const { httpClientRequestStart } = require('./channels')
7+
const { reportStackTrace } = require('./stack_trace')
68
const waf = require('./waf')
79

810
const RULE_TYPES = {
911
SSRF: 'ssrf'
1012
}
1113

12-
function enable () {
14+
let config
15+
16+
function enable (_config) {
17+
config = _config
1318
httpClientRequestStart.subscribe(analyzeSsrf)
1419
}
1520

@@ -28,12 +33,30 @@ function analyzeSsrf (ctx) {
2833
[addresses.HTTP_OUTGOING_URL]: url
2934
}
3035
// TODO: Currently this is only monitoring, we should
31-
// block the request if SSRF attempt and
32-
// generate stack traces
33-
waf.run({ persistent }, req, RULE_TYPES.SSRF)
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+
}
3456
}
3557

3658
module.exports = {
3759
enable,
38-
disable
60+
disable,
61+
handleResult
3962
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict'
2+
3+
const { calculateDDBasePath } = require('../util')
4+
5+
const ddBasePath = calculateDDBasePath(__dirname)
6+
7+
const LIBRARY_FRAMES_BUFFER = 20
8+
9+
function getCallSiteList (maxDepth = 100) {
10+
const previousPrepareStackTrace = Error.prepareStackTrace
11+
const previousStackTraceLimit = Error.stackTraceLimit
12+
let callsiteList
13+
Error.stackTraceLimit = maxDepth
14+
15+
try {
16+
Error.prepareStackTrace = function (_, callsites) {
17+
callsiteList = callsites
18+
}
19+
const e = new Error()
20+
e.stack
21+
} finally {
22+
Error.prepareStackTrace = previousPrepareStackTrace
23+
Error.stackTraceLimit = previousStackTraceLimit
24+
}
25+
26+
return callsiteList
27+
}
28+
29+
function filterOutFramesFromLibrary (callSiteList) {
30+
return callSiteList.filter(callSite => !callSite.getFileName().includes(ddBasePath))
31+
}
32+
33+
function getFramesForMetaStruct (callSiteList, maxDepth = 32) {
34+
const maxCallSite = maxDepth < 1 ? Infinity : maxDepth
35+
36+
const filteredFrames = filterOutFramesFromLibrary(callSiteList)
37+
38+
const half = filteredFrames.length > maxCallSite ? Math.round(maxCallSite / 2) : Infinity
39+
40+
const indexedFrames = []
41+
for (let i = 0; i < Math.min(filteredFrames.length, maxCallSite); i++) {
42+
const index = i < half ? i : i + filteredFrames.length - maxCallSite
43+
const callSite = filteredFrames[index]
44+
indexedFrames.push({
45+
id: index,
46+
file: callSite.getFileName(),
47+
line: callSite.getLineNumber(),
48+
column: callSite.getColumnNumber(),
49+
function: callSite.getFunctionName(),
50+
class_name: callSite.getTypeName()
51+
})
52+
}
53+
54+
return indexedFrames
55+
}
56+
57+
function reportStackTrace (rootSpan, stackId, maxDepth, maxStackTraces, callSiteListGetter = getCallSiteList) {
58+
if (!rootSpan) return
59+
60+
if (!rootSpan.meta_struct) {
61+
rootSpan.meta_struct = {}
62+
}
63+
64+
if (!rootSpan.meta_struct['_dd.stack']) {
65+
rootSpan.meta_struct['_dd.stack'] = {}
66+
}
67+
68+
if (!rootSpan.meta_struct['_dd.stack'].exploit) {
69+
rootSpan.meta_struct['_dd.stack'].exploit = []
70+
}
71+
72+
if (maxStackTraces < 1 || rootSpan.meta_struct['_dd.stack'].exploit.length < maxStackTraces) {
73+
// Since some frames will be discarded because they come from tracer codebase, a buffer is added
74+
// to the limit in order to get as close as `maxDepth` number of frames.
75+
const stackTraceLimit = maxDepth < 1 ? Infinity : maxDepth + LIBRARY_FRAMES_BUFFER
76+
const callSiteList = callSiteListGetter(stackTraceLimit)
77+
const frames = getFramesForMetaStruct(callSiteList, maxDepth)
78+
79+
rootSpan.meta_struct['_dd.stack'].exploit.push({
80+
id: stackId,
81+
language: 'nodejs',
82+
frames
83+
})
84+
}
85+
}
86+
87+
module.exports = {
88+
getCallSiteList,
89+
filterOutFramesFromLibrary,
90+
reportStackTrace
91+
}

packages/dd-trace/src/config.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,9 @@ class Config {
440440
this._setValue(defaults, 'appsec.rateLimit', 100)
441441
this._setValue(defaults, 'appsec.rules', undefined)
442442
this._setValue(defaults, 'appsec.sca.enabled', null)
443+
this._setValue(defaults, 'appsec.stackTrace.enabled', true)
444+
this._setValue(defaults, 'appsec.stackTrace.maxDepth', 32)
445+
this._setValue(defaults, 'appsec.stackTrace.maxStackTraces', 2)
443446
this._setValue(defaults, 'appsec.wafTimeout', 5e3) // µs
444447
this._setValue(defaults, 'clientIpEnabled', false)
445448
this._setValue(defaults, 'clientIpHeader', null)
@@ -524,10 +527,13 @@ class Config {
524527
DD_APPSEC_ENABLED,
525528
DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML,
526529
DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON,
530+
DD_APPSEC_MAX_STACK_TRACES,
531+
DD_APPSEC_MAX_STACK_TRACE_DEPTH,
527532
DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP,
528533
DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP,
529534
DD_APPSEC_RULES,
530535
DD_APPSEC_SCA_ENABLED,
536+
DD_APPSEC_STACK_TRACE_ENABLED,
531537
DD_APPSEC_RASP_ENABLED,
532538
DD_APPSEC_TRACE_RATE_LIMIT,
533539
DD_APPSEC_WAF_TIMEOUT,
@@ -627,6 +633,11 @@ class Config {
627633
this._setString(env, 'appsec.rules', DD_APPSEC_RULES)
628634
// DD_APPSEC_SCA_ENABLED is never used locally, but only sent to the backend
629635
this._setBoolean(env, 'appsec.sca.enabled', DD_APPSEC_SCA_ENABLED)
636+
this._setBoolean(env, 'appsec.stackTrace.enabled', DD_APPSEC_STACK_TRACE_ENABLED)
637+
this._setValue(env, 'appsec.stackTrace.maxDepth', maybeInt(DD_APPSEC_MAX_STACK_TRACE_DEPTH))
638+
this._envUnprocessed['appsec.stackTrace.maxDepth'] = DD_APPSEC_MAX_STACK_TRACE_DEPTH
639+
this._setValue(env, 'appsec.stackTrace.maxStackTraces', maybeInt(DD_APPSEC_MAX_STACK_TRACES))
640+
this._envUnprocessed['appsec.stackTrace.maxStackTraces'] = DD_APPSEC_MAX_STACK_TRACES
630641
this._setValue(env, 'appsec.wafTimeout', maybeInt(DD_APPSEC_WAF_TIMEOUT))
631642
this._envUnprocessed['appsec.wafTimeout'] = DD_APPSEC_WAF_TIMEOUT
632643
this._setBoolean(env, 'clientIpEnabled', DD_TRACE_CLIENT_IP_ENABLED)
@@ -767,6 +778,11 @@ class Config {
767778
this._setValue(opts, 'appsec.rateLimit', maybeInt(options.appsec.rateLimit))
768779
this._optsUnprocessed['appsec.rateLimit'] = options.appsec.rateLimit
769780
this._setString(opts, 'appsec.rules', options.appsec.rules)
781+
this._setBoolean(opts, 'appsec.stackTrace.enabled', options.appsec.stackTrace?.enabled)
782+
this._setValue(opts, 'appsec.stackTrace.maxDepth', maybeInt(options.appsec.stackTrace?.maxDepth))
783+
this._optsUnprocessed['appsec.stackTrace.maxDepth'] = options.appsec.stackTrace?.maxDepth
784+
this._setValue(opts, 'appsec.stackTrace.maxStackTraces', maybeInt(options.appsec.stackTrace?.maxStackTraces))
785+
this._optsUnprocessed['appsec.stackTrace.maxStackTraces'] = options.appsec.stackTrace?.maxStackTraces
770786
this._setValue(opts, 'appsec.wafTimeout', maybeInt(options.appsec.wafTimeout))
771787
this._optsUnprocessed['appsec.wafTimeout'] = options.appsec.wafTimeout
772788
this._setBoolean(opts, 'clientIpEnabled', options.clientIpEnabled)

packages/dd-trace/src/format.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function formatSpan (span) {
5353
resource: String(spanContext._name),
5454
error: 0,
5555
meta: {},
56+
meta_struct: span.meta_struct,
5657
metrics: {},
5758
start: Math.round(span._startTime * 1e6),
5859
duration: Math.round(span._duration * 1e6),

packages/dd-trace/test/appsec/index.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ describe('AppSec Index', () => {
200200
it('should call rasp enable', () => {
201201
AppSec.enable(config)
202202

203-
expect(rasp.enable).to.be.calledOnceWithExactly()
203+
expect(rasp.enable).to.be.calledOnceWithExactly(config)
204204
})
205205

206206
it('should not call rasp enable when rasp is disabled', () => {

packages/dd-trace/test/appsec/rasp.express.plugin.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ withVersions('express', 'express', expressVersion => {
7474
await agent.use((traces) => {
7575
const span = getWebSpan(traces)
7676
assert.notProperty(span.meta, '_dd.appsec.json')
77+
assert.notProperty(span.meta_struct || {}, '_dd.stack')
7778
})
7879
})
7980

@@ -92,6 +93,7 @@ withVersions('express', 'express', expressVersion => {
9293
assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1)
9394
assert(span.metrics['_dd.appsec.rasp.duration'] > 0)
9495
assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0)
96+
assert.property(span.meta_struct, '_dd.stack')
9597
})
9698
})
9799

@@ -113,6 +115,7 @@ withVersions('express', 'express', expressVersion => {
113115
assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1)
114116
assert(span.metrics['_dd.appsec.rasp.duration'] > 0)
115117
assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0)
118+
assert.property(span.meta_struct, '_dd.stack')
116119
})
117120
})
118121
})

0 commit comments

Comments
 (0)