Skip to content

Commit

Permalink
Add support to generate_stack action (#4382)
Browse files Browse the repository at this point in the history
* 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 <ugaitz.urien@datadoghq.com>

* Update packages/dd-trace/src/appsec/stack_trace.js

Co-authored-by: Ugaitz Urien <ugaitz.urien@datadoghq.com>

* 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 <ugaitz.urien@datadoghq.com>
  • Loading branch information
CarlesDD and uurien authored Jun 17, 2024
1 parent 1cd017b commit 5be6314
Show file tree
Hide file tree
Showing 13 changed files with 601 additions and 29 deletions.
5 changes: 5 additions & 0 deletions docs/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ tracer.init({
},
rasp: {
enabled: true
},
stackTrace: {
enabled: true,
maxStackTraces: 5,
maxDepth: 42
}
}
});
Expand Down
19 changes: 19 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,25 @@ declare namespace tracer {
* @default false
*/
enabled?: boolean
},
/**
* Configuration for stack trace reporting
*/
stackTrace?: {
/** Whether to enable stack trace reporting.
* @default true
*/
enabled?: boolean,

/** Specifies the maximum number of stack traces to be reported.
* @default 2
*/
maxStackTraces?: number,

/** Specifies the maximum depth of a stack trace to be reported.
* @default 32
*/
maxDepth?: number,
}
};

Expand Down
21 changes: 2 additions & 19 deletions packages/dd-trace/src/appsec/iast/path-line.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const path = require('path')
const process = require('process')
const { calculateDDBasePath } = require('../../util')
const { getCallSiteList } = require('../stack_trace')
const pathLine = {
getFirstNonDDPathAndLine,
getNodeModulesPaths,
Expand All @@ -24,24 +25,6 @@ const EXCLUDED_PATH_PREFIXES = [
'async_hooks'
]

function getCallSiteInfo () {
const previousPrepareStackTrace = Error.prepareStackTrace
const previousStackTraceLimit = Error.stackTraceLimit
let callsiteList
Error.stackTraceLimit = 100
try {
Error.prepareStackTrace = function (_, callsites) {
callsiteList = callsites
}
const e = new Error()
e.stack
} finally {
Error.prepareStackTrace = previousPrepareStackTrace
Error.stackTraceLimit = previousStackTraceLimit
}
return callsiteList
}

function getFirstNonDDPathAndLineFromCallsites (callsites, externallyExcludedPaths) {
if (callsites) {
for (let i = 0; i < callsites.length; i++) {
Expand Down Expand Up @@ -91,7 +74,7 @@ function isExcluded (callsite, externallyExcludedPaths) {
}

function getFirstNonDDPathAndLine (externallyExcludedPaths) {
return getFirstNonDDPathAndLineFromCallsites(getCallSiteInfo(), externallyExcludedPaths)
return getFirstNonDDPathAndLineFromCallsites(getCallSiteList(), externallyExcludedPaths)
}

function getNodeModulesPaths (...paths) {
Expand Down
2 changes: 1 addition & 1 deletion packages/dd-trace/src/appsec/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function enable (_config) {
graphql.enable()

if (_config.appsec.rasp.enabled) {
rasp.enable()
rasp.enable(_config)
}

setTemplates(_config)
Expand Down
33 changes: 28 additions & 5 deletions packages/dd-trace/src/appsec/rasp.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
'use strict'

const { storage } = require('../../../datadog-core')
const web = require('./../plugins/util/web')
const addresses = require('./addresses')
const { httpClientRequestStart } = require('./channels')
const { reportStackTrace } = require('./stack_trace')
const waf = require('./waf')

const RULE_TYPES = {
SSRF: 'ssrf'
}

function enable () {
let config

function enable (_config) {
config = _config
httpClientRequestStart.subscribe(analyzeSsrf)
}

Expand All @@ -28,12 +33,30 @@ function analyzeSsrf (ctx) {
[addresses.HTTP_OUTGOING_URL]: url
}
// TODO: Currently this is only monitoring, we should
// block the request if SSRF attempt and
// generate stack traces
waf.run({ persistent }, req, RULE_TYPES.SSRF)
// block the request if SSRF attempt
const result = waf.run({ persistent }, req, RULE_TYPES.SSRF)
handleResult(result, req)
}

function getGenerateStackTraceAction (actions) {
return actions?.generate_stack
}

function handleResult (actions, req) {
const generateStackTraceAction = getGenerateStackTraceAction(actions)
if (generateStackTraceAction && config.appsec.stackTrace.enabled) {
const rootSpan = web.root(req)
reportStackTrace(
rootSpan,
generateStackTraceAction.stack_id,
config.appsec.stackTrace.maxDepth,
config.appsec.stackTrace.maxStackTraces
)
}
}

module.exports = {
enable,
disable
disable,
handleResult
}
91 changes: 91 additions & 0 deletions packages/dd-trace/src/appsec/stack_trace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use strict'

const { calculateDDBasePath } = require('../util')

const ddBasePath = calculateDDBasePath(__dirname)

const LIBRARY_FRAMES_BUFFER = 20

function getCallSiteList (maxDepth = 100) {
const previousPrepareStackTrace = Error.prepareStackTrace
const previousStackTraceLimit = Error.stackTraceLimit
let callsiteList
Error.stackTraceLimit = maxDepth

try {
Error.prepareStackTrace = function (_, callsites) {
callsiteList = callsites
}
const e = new Error()
e.stack
} finally {
Error.prepareStackTrace = previousPrepareStackTrace
Error.stackTraceLimit = previousStackTraceLimit
}

return callsiteList
}

function filterOutFramesFromLibrary (callSiteList) {
return callSiteList.filter(callSite => !callSite.getFileName().includes(ddBasePath))
}

function getFramesForMetaStruct (callSiteList, maxDepth = 32) {
const maxCallSite = maxDepth < 1 ? Infinity : maxDepth

const filteredFrames = filterOutFramesFromLibrary(callSiteList)

const half = filteredFrames.length > maxCallSite ? Math.round(maxCallSite / 2) : Infinity

const indexedFrames = []
for (let i = 0; i < Math.min(filteredFrames.length, maxCallSite); i++) {
const index = i < half ? i : i + filteredFrames.length - maxCallSite
const callSite = filteredFrames[index]
indexedFrames.push({
id: index,
file: callSite.getFileName(),
line: callSite.getLineNumber(),
column: callSite.getColumnNumber(),
function: callSite.getFunctionName(),
class_name: callSite.getTypeName()
})
}

return indexedFrames
}

function reportStackTrace (rootSpan, stackId, maxDepth, maxStackTraces, callSiteListGetter = getCallSiteList) {
if (!rootSpan) return

if (!rootSpan.meta_struct) {
rootSpan.meta_struct = {}
}

if (!rootSpan.meta_struct['_dd.stack']) {
rootSpan.meta_struct['_dd.stack'] = {}
}

if (!rootSpan.meta_struct['_dd.stack'].exploit) {
rootSpan.meta_struct['_dd.stack'].exploit = []
}

if (maxStackTraces < 1 || rootSpan.meta_struct['_dd.stack'].exploit.length < maxStackTraces) {
// Since some frames will be discarded because they come from tracer codebase, a buffer is added
// to the limit in order to get as close as `maxDepth` number of frames.
const stackTraceLimit = maxDepth < 1 ? Infinity : maxDepth + LIBRARY_FRAMES_BUFFER
const callSiteList = callSiteListGetter(stackTraceLimit)
const frames = getFramesForMetaStruct(callSiteList, maxDepth)

rootSpan.meta_struct['_dd.stack'].exploit.push({
id: stackId,
language: 'nodejs',
frames
})
}
}

module.exports = {
getCallSiteList,
filterOutFramesFromLibrary,
reportStackTrace
}
16 changes: 16 additions & 0 deletions packages/dd-trace/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,9 @@ class Config {
this._setValue(defaults, 'appsec.rateLimit', 100)
this._setValue(defaults, 'appsec.rules', undefined)
this._setValue(defaults, 'appsec.sca.enabled', null)
this._setValue(defaults, 'appsec.stackTrace.enabled', true)
this._setValue(defaults, 'appsec.stackTrace.maxDepth', 32)
this._setValue(defaults, 'appsec.stackTrace.maxStackTraces', 2)
this._setValue(defaults, 'appsec.wafTimeout', 5e3) // µs
this._setValue(defaults, 'clientIpEnabled', false)
this._setValue(defaults, 'clientIpHeader', null)
Expand Down Expand Up @@ -524,10 +527,13 @@ class Config {
DD_APPSEC_ENABLED,
DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML,
DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON,
DD_APPSEC_MAX_STACK_TRACES,
DD_APPSEC_MAX_STACK_TRACE_DEPTH,
DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP,
DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP,
DD_APPSEC_RULES,
DD_APPSEC_SCA_ENABLED,
DD_APPSEC_STACK_TRACE_ENABLED,
DD_APPSEC_RASP_ENABLED,
DD_APPSEC_TRACE_RATE_LIMIT,
DD_APPSEC_WAF_TIMEOUT,
Expand Down Expand Up @@ -627,6 +633,11 @@ class Config {
this._setString(env, 'appsec.rules', DD_APPSEC_RULES)
// DD_APPSEC_SCA_ENABLED is never used locally, but only sent to the backend
this._setBoolean(env, 'appsec.sca.enabled', DD_APPSEC_SCA_ENABLED)
this._setBoolean(env, 'appsec.stackTrace.enabled', DD_APPSEC_STACK_TRACE_ENABLED)
this._setValue(env, 'appsec.stackTrace.maxDepth', maybeInt(DD_APPSEC_MAX_STACK_TRACE_DEPTH))
this._envUnprocessed['appsec.stackTrace.maxDepth'] = DD_APPSEC_MAX_STACK_TRACE_DEPTH
this._setValue(env, 'appsec.stackTrace.maxStackTraces', maybeInt(DD_APPSEC_MAX_STACK_TRACES))
this._envUnprocessed['appsec.stackTrace.maxStackTraces'] = DD_APPSEC_MAX_STACK_TRACES
this._setValue(env, 'appsec.wafTimeout', maybeInt(DD_APPSEC_WAF_TIMEOUT))
this._envUnprocessed['appsec.wafTimeout'] = DD_APPSEC_WAF_TIMEOUT
this._setBoolean(env, 'clientIpEnabled', DD_TRACE_CLIENT_IP_ENABLED)
Expand Down Expand Up @@ -767,6 +778,11 @@ class Config {
this._setValue(opts, 'appsec.rateLimit', maybeInt(options.appsec.rateLimit))
this._optsUnprocessed['appsec.rateLimit'] = options.appsec.rateLimit
this._setString(opts, 'appsec.rules', options.appsec.rules)
this._setBoolean(opts, 'appsec.stackTrace.enabled', options.appsec.stackTrace?.enabled)
this._setValue(opts, 'appsec.stackTrace.maxDepth', maybeInt(options.appsec.stackTrace?.maxDepth))
this._optsUnprocessed['appsec.stackTrace.maxDepth'] = options.appsec.stackTrace?.maxDepth
this._setValue(opts, 'appsec.stackTrace.maxStackTraces', maybeInt(options.appsec.stackTrace?.maxStackTraces))
this._optsUnprocessed['appsec.stackTrace.maxStackTraces'] = options.appsec.stackTrace?.maxStackTraces
this._setValue(opts, 'appsec.wafTimeout', maybeInt(options.appsec.wafTimeout))
this._optsUnprocessed['appsec.wafTimeout'] = options.appsec.wafTimeout
this._setBoolean(opts, 'clientIpEnabled', options.clientIpEnabled)
Expand Down
1 change: 1 addition & 0 deletions packages/dd-trace/src/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function formatSpan (span) {
resource: String(spanContext._name),
error: 0,
meta: {},
meta_struct: span.meta_struct,
metrics: {},
start: Math.round(span._startTime * 1e6),
duration: Math.round(span._duration * 1e6),
Expand Down
2 changes: 1 addition & 1 deletion packages/dd-trace/test/appsec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ describe('AppSec Index', () => {
it('should call rasp enable', () => {
AppSec.enable(config)

expect(rasp.enable).to.be.calledOnceWithExactly()
expect(rasp.enable).to.be.calledOnceWithExactly(config)
})

it('should not call rasp enable when rasp is disabled', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/dd-trace/test/appsec/rasp.express.plugin.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ withVersions('express', 'express', expressVersion => {
await agent.use((traces) => {
const span = getWebSpan(traces)
assert.notProperty(span.meta, '_dd.appsec.json')
assert.notProperty(span.meta_struct || {}, '_dd.stack')
})
})

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

Expand All @@ -113,6 +115,7 @@ withVersions('express', 'express', expressVersion => {
assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1)
assert(span.metrics['_dd.appsec.rasp.duration'] > 0)
assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0)
assert.property(span.meta_struct, '_dd.stack')
})
})
})
Expand Down
Loading

0 comments on commit 5be6314

Please sign in to comment.