Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions packages/dd-trace/src/appsec/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ module.exports = {
httpClientResponseFinish: dc.channel('apm:http:client:response:finish'),
incomingHttpRequestEnd: dc.channel('dd-trace:incomingHttpRequestEnd'),
incomingHttpRequestStart: dc.channel('dd-trace:incomingHttpRequestStart'),
lambdaStartInvocation: dc.channel('datadog:lambda:start-invocation'),
lambdaEndInvocation: dc.channel('datadog:lambda:end-invocation'),
multerParser: dc.channel('datadog:multer:read:finish'),
mysql2OuterQueryStart: dc.channel('datadog:mysql2:outerquery:start'),
nextBodyParsed: dc.channel('apm:next:body-parsed'),
Expand Down
11 changes: 10 additions & 1 deletion packages/dd-trace/src/appsec/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const {
fastifyCookieParser,
incomingHttpRequestStart,
incomingHttpRequestEnd,
lambdaStartInvocation,
lambdaEndInvocation,
passportVerify,
passportUser,
expressSession,
Expand Down Expand Up @@ -43,6 +45,7 @@ const { isBlocked, block, callBlockDelegation, setTemplates, getBlockingAction }
const { getActiveRequest } = require('./store')
const UserTracking = require('./user_tracking')
const graphql = require('./graphql')
const lambda = require('./lambda')
const rasp = require('./rasp')

const responseAnalyzedSet = new WeakSet()
Expand Down Expand Up @@ -99,11 +102,15 @@ function enable (_config) {
stripeCheckoutSessionCreate.subscribe(onStripeCheckoutSessionCreate)
stripePaymentIntentCreate.subscribe(onStripePaymentIntentCreate)
stripeConstructEvent.subscribe(onStripeConstructEvent)
lambdaStartInvocation.subscribe(lambda.onLambdaStartInvocation)
lambdaEndInvocation.subscribe(lambda.onLambdaEndInvocation)

isEnabled = true
config = _config
} catch (err) {
if (!IS_SERVERLESS) {
if (IS_SERVERLESS) {
log.debug('[ASM] Serverless mode: suppressing error log, calling disable()')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's helpful to attach the error here for later investigations.

} else {
log.error('[ASM] Unable to start AppSec', err)
}

Expand Down Expand Up @@ -544,6 +551,8 @@ function disable () {
if (stripeCheckoutSessionCreate.hasSubscribers) stripeCheckoutSessionCreate.unsubscribe(onStripeCheckoutSessionCreate)
if (stripePaymentIntentCreate.hasSubscribers) stripePaymentIntentCreate.unsubscribe(onStripePaymentIntentCreate)
if (stripeConstructEvent.hasSubscribers) stripeConstructEvent.unsubscribe(onStripeConstructEvent)
if (lambdaStartInvocation.hasSubscribers) lambdaStartInvocation.unsubscribe(lambda.onLambdaStartInvocation)
if (lambdaEndInvocation.hasSubscribers) lambdaEndInvocation.unsubscribe(lambda.onLambdaEndInvocation)
}

/**
Expand Down
132 changes: 132 additions & 0 deletions packages/dd-trace/src/appsec/lambda.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use strict'

const { HTTP_CLIENT_IP } = require('../../../../ext/tags')

const log = require('../log')
const addresses = require('./addresses')
const Reporter = require('./reporter')
const waf = require('./waf')

// Tracks spans for which start-invocation has been processed, so that
// end-invocation can gate correctly
const activeInvocations = new WeakSet()

/**
* Maps pre-extracted HTTP data from the Lambda event to WAF addresses,
* runs the WAF, and reports results on the span.
*
* @param {{ span: object, headers: Record<string, string>, method: string, path: string,
* query: Record<string, string | string[]> | undefined, body: string | object | undefined,
* isBase64Encoded: boolean, clientIp: string | undefined,
* pathParams: Record<string, string> | undefined,
* cookies: Record<string, string> | undefined,
* route: string | undefined }} data
*/
function onLambdaStartInvocation (data) {
try {
const { span, headers, method, path, query, body, clientIp, pathParams, cookies } = data

if (!span) {
log.warn('[ASM] No span provided in Lambda start invocation')
return
}

activeInvocations.add(span)

span.setTag('_dd.appsec.enabled', 1)

const persistent = {}

if (path) {
persistent[addresses.HTTP_INCOMING_URL] = path
Comment thread
uurien marked this conversation as resolved.
}

if (method) {
persistent[addresses.HTTP_INCOMING_METHOD] = method
}

if (headers) {
// Cookie header is already stripped by the Lambda layer's event-data-extractor
persistent[addresses.HTTP_INCOMING_HEADERS] = headers
}
Comment thread
simon-id marked this conversation as resolved.

if (clientIp) {
span.setTag(HTTP_CLIENT_IP, clientIp)
persistent[addresses.HTTP_CLIENT_IP] = clientIp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add span.setTag(HTTP_CLIENT_IP, clientIp) here and remove first block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The two separate conditionals are now merged into one.

}

if (query) {
persistent[addresses.HTTP_INCOMING_QUERY] = query
}

if (body !== undefined && body !== null) {
persistent[addresses.HTTP_INCOMING_BODY] = body
}

if (pathParams) {
persistent[addresses.HTTP_INCOMING_PARAMS] = pathParams
}

if (cookies) {
persistent[addresses.HTTP_INCOMING_COOKIES] = cookies
}

waf.run({ persistent }, span, undefined, span)
} catch (err) {
log.error('[ASM] Error in Lambda start-invocation handler', err)
}
}

/**
* Maps response data to WAF addresses, runs a final WAF pass,
* disposes the WAF context, and finishes the request report.
*
* @param {{ span: object, statusCode: string | undefined,
* responseHeaders: Record<string, string> | undefined }} data
*/
function onLambdaEndInvocation (data) {
try {
const { span, statusCode, responseHeaders } = data

if (!span) {
log.warn('[ASM] No span provided in Lambda end invocation')
return
}

if (!activeInvocations.has(span)) {
return
}

activeInvocations.delete(span)

let hasPersistentData = false
const persistent = {}

if (statusCode) {
persistent[addresses.HTTP_INCOMING_RESPONSE_CODE] = String(statusCode)
hasPersistentData = true
}

if (responseHeaders) {
const filteredHeaders = { ...responseHeaders }
delete filteredHeaders['set-cookie']
persistent[addresses.HTTP_INCOMING_RESPONSE_HEADERS] = filteredHeaders
hasPersistentData = true
}

if (hasPersistentData) {
waf.run({ persistent }, span, undefined, span)
}

waf.disposeContext(span)

Reporter.finishRequest(span, null, {}, undefined, span)
} catch (err) {
log.error('[ASM] Error in Lambda end-invocation handler', err)
}
}

module.exports = {
onLambdaStartInvocation,
onLambdaEndInvocation,
}
59 changes: 49 additions & 10 deletions packages/dd-trace/src/appsec/reporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,21 @@ function reportWafConfigUpdate (product, rcConfigId, diagnostics, wafVersion) {
}
}

function reportMetrics (metrics, raspRule, req) {
/**
* @param {object} metrics - WAF run metrics
* @param {string} [raspRule] - RASP rule identifier
* @param {object} [req] - Request key (plain object for lambda)
* @param {object} [rootSpan] - Root span (required for lambda)
*/
function reportMetrics (metrics, raspRule, req, rootSpan) {
if (!req) {
req = getActiveRequest()
}
const rootSpan = req && web.root(req)
if (!rootSpan) {
rootSpan = req && web.root(req)
}

if (!rootSpan) return
if (!req || !rootSpan) return

if (metrics.rulesVersion) {
rootSpan.setTag('_dd.appsec.event_rules.version', metrics.rulesVersion)
Expand Down Expand Up @@ -353,13 +361,26 @@ function reportTruncationMetrics (rootSpan, metrics) {
}
}

function reportAttack ({ events: attackData, actions }, req) {
// NOTE: `req` in the WAF execution path may be any object, not necessarily
// an HTTP IncomingMessage. In Lambda, it is a plain context key ({}) with no
// HTTP properties. Always guard HTTP-specific property access
// See tests in lambda.spec.js for enforcement.

/**
* @param {{ events: Array, actions: object }} result - WAF result with attack data
* @param {object} [req] - Request key. May be an HTTP IncomingMessage or a plain object (Lambda)
* @param {object} [rootSpan] - Root span (required for lambda)
*/
function reportAttack ({ events: attackData, actions }, req, rootSpan) {
if (!req) {
req = getActiveRequest()
}
Comment on lines +374 to 377

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm worried that in this function, if we don't pass the empty object fake "req", it will try to get it from the store, which would make this req variable here undefined, and crash later down the function because it's trying to access props in req (req.socket, req.body, etc).

The safety mecanism that was in place before was the rootSpan check. If req was missing, then rootSpan would also be missing, and thus the if(!rootSpan) return would protect us from undefined req.
But now that we can pass the rootSpan separately from req, this safety check is inoperant. Does that make sense ?

Can you prove to me that req can never be empty here ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, your concern is legit: decoupling rootSpan from req breaks the implicit safety mechanism. But in the lambda path, req is never null or undefined in reportAttack (it's an invocationKey ({}), a truthy object flowing from lambda.js). The only place where req is null is in finishRequest.

I've added a test to check the WAF path with a strict req object that throws on any unaudited property access, making it fail if someone adds req.something without a guard in this path.


const rootSpan = web.root(req)
if (!rootSpan) return
if (!rootSpan) {
rootSpan = web.root(req)
}

if (!req || !rootSpan) return

const spanContext = rootSpan.context()

Expand Down Expand Up @@ -494,14 +515,21 @@ function isSchemaAttribute (attribute) {
return attribute.startsWith('_dd.appsec.s.')
}

function reportAttributes (attributes, req) {
/**
* @param {object} [attributes] - WAF result attributes
* @param {object} [req] - Request key (plain object for lambda)
* @param {object} [rootSpan] - Root span (required for lambda)
*/
function reportAttributes (attributes, req, rootSpan) {
if (!attributes) return

if (!req) {
req = getActiveRequest()
}

const rootSpan = web.root(req)
if (!rootSpan) {
rootSpan = web.root(req)
}

if (!rootSpan) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reportAttributes doesn't use req after getting rootSpan, so the !req guard isn't needed here (unlike reportAttack, which accesses req.socket, web.getContext(req), and req.body after the guard.

There is also an existing test that calls reportAttributes without a req and expects addTags to be called, relying on web.root(undefined) resolving to a span. Adding !req would break that path.


Expand All @@ -517,8 +545,17 @@ function reportAttributes (attributes, req) {
rootSpan.addTags(tags)
}

function finishRequest (req, res, storedResponseHeaders, requestBody) {
const rootSpan = web.root(req)
/**
* @param {object} [req] - Request key (null for Lambda)
* @param {object} [res] - Response object (null for lambda)
* @param {object} storedResponseHeaders
* @param {object} [requestBody]
* @param {object} [rootSpan] - Root span (required for lambda)
*/
function finishRequest (req, res, storedResponseHeaders, requestBody, rootSpan) {
if (!rootSpan) {
rootSpan = web.root(req)
}
if (!rootSpan) return

if (metricsQueue.size) {
Expand Down Expand Up @@ -569,6 +606,8 @@ function finishRequest (req, res, storedResponseHeaders, requestBody) {
rootSpan.setTag('_dd.appsec.rasp.rule.eval', metrics.raspEvalCount)
}

if (!req) return

incrementWafRequestsMetric(req)

const tags = rootSpan.context().getTags()
Expand Down
20 changes: 16 additions & 4 deletions packages/dd-trace/src/appsec/waf/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,19 @@ function removeConfig (configPath) {
}
}

function run (data, req, raspRule) {
// `req` in the WAF execution path may be any object, not necessarily
// an HTTP IncomingMessage (it is a plain object for lambda).
// Always guard HTTP-specific property access.
// @see packages/dd-trace/test/appsec/lambda.spec.js

/**
* @param {object} data - WAF address data ({ persistent, ephemeral })
* @param {object} req - Request key for WAF context lookup. May be an HTTP
* IncomingMessage or a plain object (Lambda invocation key).
* @param {string} [raspRule] - RASP rule identifier
* @param {object} [rootSpan] - Root span to tag (required for Lambda)
*/
function run (data, req, raspRule, rootSpan) {
if (!req) {
req = getActiveRequest()
if (!req) {
Expand All @@ -120,12 +132,12 @@ function run (data, req, raspRule) {
}

const wafContext = waf.wafManager.getWAFContext(req)
const result = wafContext.run(data, raspRule, req)
const result = wafContext.run(data, raspRule, req, rootSpan)

if (result?.keep) {
if (limiter.isAllowed()) {
const rootSpan = web.root(req)
keepTrace(rootSpan, ASM)
const span = rootSpan || web.root(req)
keepTrace(span, ASM)
} else {
updateRateLimitedMetric(req)
}
Expand Down
8 changes: 4 additions & 4 deletions packages/dd-trace/src/appsec/waf/waf_context_wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class WAFContextWrapper {
}
}

run ({ persistent, ephemeral }, raspRule, req) {
run ({ persistent, ephemeral }, raspRule, req, rootSpan) {
if (this.ddwafContext.disposed) {
log.warn('[ASM] Calling run on a disposed context')
if (raspRule) {
Expand Down Expand Up @@ -156,10 +156,10 @@ class WAFContextWrapper {
metrics.wafTimeout = result.timeout

if (ruleTriggered) {
Reporter.reportAttack(result, req)
Reporter.reportAttack(result, req, rootSpan)
}

Reporter.reportAttributes(result.attributes, req)
Reporter.reportAttributes(result.attributes, req, rootSpan)

return result
} catch (err) {
Expand All @@ -171,7 +171,7 @@ class WAFContextWrapper {
wafRunFinished.publish({ payload })
}

Reporter.reportMetrics(metrics, raspRule, req)
Reporter.reportMetrics(metrics, raspRule, req, rootSpan)
}
}

Expand Down
Loading
Loading