Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to generate_stack action #4382

Merged
merged 19 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
simon-id marked this conversation as resolved.
Show resolved Hide resolved
}
simon-id marked this conversation as resolved.
Show resolved Hide resolved

function getFramesForMetaStruct (callSiteList, maxDepth = 32) {
const maxCallSite = maxDepth < 1 ? Infinity : maxDepth
simon-id marked this conversation as resolved.
Show resolved Hide resolved

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 = callSiteList[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) {
uurien marked this conversation as resolved.
Show resolved Hide resolved
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({
CarlesDD marked this conversation as resolved.
Show resolved Hide resolved
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))
uurien marked this conversation as resolved.
Show resolved Hide resolved
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
Loading