From f6edc6e09f272956611b5f6967b81d990aa80e7b Mon Sep 17 00:00:00 2001 From: ishabi Date: Mon, 23 Dec 2024 15:44:24 +0100 Subject: [PATCH] Report stack trace in iast --- .../appsec/iast/analyzers/cookie-analyzer.js | 6 ++-- .../iast/analyzers/vulnerability-analyzer.js | 12 ++++--- .../dd-trace/src/appsec/iast/iast-context.js | 12 +++++++ .../dd-trace/src/appsec/iast/path-line.js | 5 ++- .../iast/vulnerabilities-formatter/index.js | 1 + .../src/appsec/iast/vulnerability-reporter.js | 28 +++++++++++++++-- packages/dd-trace/src/appsec/rasp/utils.js | 12 +++++-- packages/dd-trace/src/appsec/stack_trace.js | 31 ++++++++++++------- packages/dd-trace/test/appsec/iast/utils.js | 16 ++++++++++ 9 files changed, 96 insertions(+), 27 deletions(-) diff --git a/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js index a898a0a379c..75117d78bde 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/cookie-analyzer.js @@ -54,15 +54,15 @@ class CookieAnalyzer extends Analyzer { return super._checkOCE(context, value) } - _getLocation (value) { + _getLocation (value, callSiteList) { if (!value) { - return super._getLocation() + return super._getLocation(value, callSiteList) } if (value.location) { return value.location } - const location = super._getLocation(value) + const location = super._getLocation(value, callSiteList) value.location = location return location } diff --git a/packages/dd-trace/src/appsec/iast/analyzers/vulnerability-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/vulnerability-analyzer.js index f79e7a44f71..e66d8c20e57 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/vulnerability-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/vulnerability-analyzer.js @@ -2,7 +2,7 @@ const { storage } = require('../../../../../datadog-core') const { getFirstNonDDPathAndLine } = require('../path-line') -const { addVulnerability } = require('../vulnerability-reporter') +const { addVulnerability, getVulnerabilityCallSiteList } = require('../vulnerability-reporter') const { getIastContext } = require('../iast-context') const overheadController = require('../overhead-controller') const { SinkIastPlugin } = require('../iast-plugin') @@ -28,12 +28,14 @@ class Analyzer extends SinkIastPlugin { } _reportEvidence (value, context, evidence) { - const location = this._getLocation(value) + const callSiteList = getVulnerabilityCallSiteList() + const location = this._getLocation(value, callSiteList) + if (!this._isExcluded(location)) { const locationSourceMap = this._replaceLocationFromSourceMap(location) const spanId = context && context.rootSpan && context.rootSpan.context().toSpanId() const vulnerability = this._createVulnerability(this._type, evidence, spanId, locationSourceMap) - addVulnerability(context, vulnerability) + addVulnerability(context, vulnerability, callSiteList) } } @@ -49,8 +51,8 @@ class Analyzer extends SinkIastPlugin { return { value } } - _getLocation () { - return getFirstNonDDPathAndLine(this._getExcludedPaths()) + _getLocation (value, callSiteList) { + return getFirstNonDDPathAndLine(callSiteList, this._getExcludedPaths()) } _replaceLocationFromSourceMap (location) { diff --git a/packages/dd-trace/src/appsec/iast/iast-context.js b/packages/dd-trace/src/appsec/iast/iast-context.js index 6d697dcf978..eb98d81e17c 100644 --- a/packages/dd-trace/src/appsec/iast/iast-context.js +++ b/packages/dd-trace/src/appsec/iast/iast-context.js @@ -9,6 +9,17 @@ function getIastContext (store, topContext) { return iastContext } +function getIastStackTraceId (iastContext) { + if (!iastContext) return 0 + + if (!iastContext.iastStackTraceId) { + iastContext.iastStackTraceId = 0 + } + + iastContext.iastStackTraceId += 1 + return iastContext.iastStackTraceId +} + /* TODO Fix storage problem when the close event is called without finish event to remove `topContext` references We have to save the context in two places, because @@ -51,6 +62,7 @@ module.exports = { getIastContext, saveIastContext, cleanIastContext, + getIastStackTraceId, IAST_CONTEXT_KEY, IAST_TRANSACTION_ID } diff --git a/packages/dd-trace/src/appsec/iast/path-line.js b/packages/dd-trace/src/appsec/iast/path-line.js index bf7c3eb2d84..3c034aad8f2 100644 --- a/packages/dd-trace/src/appsec/iast/path-line.js +++ b/packages/dd-trace/src/appsec/iast/path-line.js @@ -3,7 +3,6 @@ const path = require('path') const process = require('process') const { calculateDDBasePath } = require('../../util') -const { getCallSiteList } = require('../stack_trace') const pathLine = { getFirstNonDDPathAndLine, getNodeModulesPaths, @@ -73,8 +72,8 @@ function isExcluded (callsite, externallyExcludedPaths) { return false } -function getFirstNonDDPathAndLine (externallyExcludedPaths) { - return getFirstNonDDPathAndLineFromCallsites(getCallSiteList(), externallyExcludedPaths) +function getFirstNonDDPathAndLine (callSiteList, externallyExcludedPaths) { + return getFirstNonDDPathAndLineFromCallsites(callSiteList, externallyExcludedPaths) } function getNodeModulesPaths (...paths) { diff --git a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/index.js b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/index.js index d704743dde4..88af720a285 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/index.js +++ b/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/index.js @@ -84,6 +84,7 @@ class VulnerabilityFormatter { const formattedVulnerability = { type: vulnerability.type, hash: vulnerability.hash, + stackId: vulnerability.stackId, evidence: this.formatEvidence(vulnerability.type, vulnerability.evidence, sourcesIndexes, sources), location: { spanId: vulnerability.location.spanId diff --git a/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js b/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js index 05aea14cf02..961dd4a2cbb 100644 --- a/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +++ b/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js @@ -6,6 +6,8 @@ const { IAST_ENABLED_TAG_KEY, IAST_JSON_TAG_KEY } = require('./tags') const standalone = require('../standalone') const { SAMPLING_MECHANISM_APPSEC } = require('../../constants') const { keepTrace } = require('../../priority_sampler') +const { reportStackTrace, getCallSiteList, STACK_TRACE_NAMESPACES } = require('../stack_trace') +const { getIastStackTraceId } = require('./iast-context') const VULNERABILITIES_KEY = 'vulnerabilities' const VULNERABILITY_HASHES_MAX_SIZE = 1000 @@ -15,8 +17,10 @@ const RESET_VULNERABILITY_CACHE_INTERVAL = 60 * 60 * 1000 // 1 hour let tracer let resetVulnerabilityCacheTimer let deduplicationEnabled = true +let stackTraceMaxDepth +let maxStackTraces -function addVulnerability (iastContext, vulnerability) { +function addVulnerability (iastContext, vulnerability, callSiteList) { if (vulnerability?.evidence && vulnerability?.type && vulnerability?.location) { if (deduplicationEnabled && isDuplicatedVulnerability(vulnerability)) return @@ -41,6 +45,18 @@ function addVulnerability (iastContext, vulnerability) { keepTrace(span, SAMPLING_MECHANISM_APPSEC) standalone.sample(span) + const stackId = getIastStackTraceId(iastContext) + vulnerability.stackId = stackId + + reportStackTrace( + iastContext?.rootSpan, + stackId, + stackTraceMaxDepth, + maxStackTraces, + callSiteList, + STACK_TRACE_NAMESPACES.IAST + ) + if (iastContext?.rootSpan) { iastContext[VULNERABILITIES_KEY] = iastContext[VULNERABILITIES_KEY] || [] iastContext[VULNERABILITIES_KEY].push(vulnerability) @@ -91,11 +107,18 @@ function stopClearCacheTimer () { } function isDuplicatedVulnerability (vulnerability) { - return VULNERABILITY_HASHES.get(`${vulnerability.type}${vulnerability.hash}`) + return VULNERABILITY_HASHES.has(`${vulnerability.type}${vulnerability.hash}`) +} + +function getVulnerabilityCallSiteList () { + return getCallSiteList(stackTraceMaxDepth) } function start (config, _tracer) { deduplicationEnabled = config.iast.deduplicationEnabled + stackTraceMaxDepth = config.appsec.stackTrace.maxDepth + maxStackTraces = config.appsec.stackTrace.maxStackTraces + vulnerabilitiesFormatter.setRedactVulnerabilities( config.iast.redactionEnabled, config.iast.redactionNamePattern, @@ -114,6 +137,7 @@ function stop () { module.exports = { addVulnerability, sendVulnerabilities, + getVulnerabilityCallSiteList, clearCache, start, stop diff --git a/packages/dd-trace/src/appsec/rasp/utils.js b/packages/dd-trace/src/appsec/rasp/utils.js index a454a71b8c6..edca8a9ebd4 100644 --- a/packages/dd-trace/src/appsec/rasp/utils.js +++ b/packages/dd-trace/src/appsec/rasp/utils.js @@ -1,7 +1,7 @@ 'use strict' const web = require('../../plugins/util/web') -const { reportStackTrace } = require('../stack_trace') +const { getCallSiteList, reportStackTrace } = require('../stack_trace') const { getBlockingAction } = require('../blocking') const log = require('../../log') @@ -30,13 +30,19 @@ class DatadogRaspAbortError extends Error { function handleResult (actions, req, res, abortController, config) { const generateStackTraceAction = actions?.generate_stack + + const { maxDepth, maxStackTraces } = config.appsec.stackTrace + + const callSiteList = getCallSiteList(maxDepth) + if (generateStackTraceAction && config.appsec.stackTrace.enabled) { const rootSpan = web.root(req) reportStackTrace( rootSpan, generateStackTraceAction.stack_id, - config.appsec.stackTrace.maxDepth, - config.appsec.stackTrace.maxStackTraces + maxDepth, + maxStackTraces, + callSiteList ) } diff --git a/packages/dd-trace/src/appsec/stack_trace.js b/packages/dd-trace/src/appsec/stack_trace.js index ea49ed1e877..dd9a0f4cfb5 100644 --- a/packages/dd-trace/src/appsec/stack_trace.js +++ b/packages/dd-trace/src/appsec/stack_trace.js @@ -6,11 +6,22 @@ const ddBasePath = calculateDDBasePath(__dirname) const LIBRARY_FRAMES_BUFFER = 20 +const STACK_TRACE_NAMESPACES = { + RASP: 'exploit', + IAST: 'vulnerability' +} + function getCallSiteList (maxDepth = 100) { + if (maxDepth < 1) { + maxDepth = Infinity + } + const previousPrepareStackTrace = Error.prepareStackTrace const previousStackTraceLimit = Error.stackTraceLimit let callsiteList - Error.stackTraceLimit = maxDepth + // 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. + Error.stackTraceLimit = maxDepth + LIBRARY_FRAMES_BUFFER try { Error.prepareStackTrace = function (_, callsites) { @@ -52,14 +63,11 @@ function getFramesForMetaStruct (callSiteList, maxDepth = 32) { return indexedFrames } -function reportStackTrace (rootSpan, stackId, maxDepth, maxStackTraces, callSiteListGetter = getCallSiteList) { +function reportStackTrace ( + rootSpan, stackId, maxDepth, maxStackTraces, callSiteList, namespace = STACK_TRACE_NAMESPACES.RASP) { if (!rootSpan) return - if (maxStackTraces < 1 || (rootSpan.meta_struct?.['_dd.stack']?.exploit?.length ?? 0) < 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. - if (maxDepth < 1) maxDepth = Infinity - const callSiteList = callSiteListGetter(maxDepth + LIBRARY_FRAMES_BUFFER) + if (maxStackTraces < 1 || (rootSpan.meta_struct?.['_dd.stack']?.[namespace]?.length ?? 0) < maxStackTraces) { if (!Array.isArray(callSiteList)) return if (!rootSpan.meta_struct) { @@ -70,13 +78,13 @@ function reportStackTrace (rootSpan, stackId, maxDepth, maxStackTraces, callSite rootSpan.meta_struct['_dd.stack'] = {} } - if (!rootSpan.meta_struct['_dd.stack'].exploit) { - rootSpan.meta_struct['_dd.stack'].exploit = [] + if (!rootSpan.meta_struct['_dd.stack'][namespace]) { + rootSpan.meta_struct['_dd.stack'][namespace] = [] } const frames = getFramesForMetaStruct(callSiteList, maxDepth) - rootSpan.meta_struct['_dd.stack'].exploit.push({ + rootSpan.meta_struct['_dd.stack'][namespace].push({ id: stackId, language: 'nodejs', frames @@ -86,5 +94,6 @@ function reportStackTrace (rootSpan, stackId, maxDepth, maxStackTraces, callSite module.exports = { getCallSiteList, - reportStackTrace + reportStackTrace, + STACK_TRACE_NAMESPACES } diff --git a/packages/dd-trace/test/appsec/iast/utils.js b/packages/dd-trace/test/appsec/iast/utils.js index 01274dd954e..ee6abe13cba 100644 --- a/packages/dd-trace/test/appsec/iast/utils.js +++ b/packages/dd-trace/test/appsec/iast/utils.js @@ -3,6 +3,7 @@ const fs = require('fs') const os = require('os') const path = require('path') +const { assert } = require('chai') const agent = require('../../plugins/agent') const axios = require('axios') @@ -10,6 +11,17 @@ const iast = require('../../../src/appsec/iast') const Config = require('../../../src/config') const vulnerabilityReporter = require('../../../src/appsec/iast/vulnerability-reporter') +function getWebSpan (traces) { + for (const trace of traces) { + for (const span of trace) { + if (span.type === 'web') { + return span + } + } + } + throw new Error('web span not found') +} + function testInRequest (app, tests) { let http let listener @@ -161,6 +173,10 @@ function checkVulnerabilityInRequest (vulnerability, occurrencesAndLocation, cb, .use(traces => { expect(traces[0][0].metrics['_dd.iast.enabled']).to.be.equal(1) expect(traces[0][0].meta).to.have.property('_dd.iast.json') + + const span = getWebSpan(traces) + assert.property(span.meta_struct, '_dd.stack') + const vulnerabilitiesTrace = JSON.parse(traces[0][0].meta['_dd.iast.json']) expect(vulnerabilitiesTrace).to.not.be.null const vulnerabilitiesCount = new Map()