Skip to content

Commit 14496f3

Browse files
CarlesDDBridgeAR
authored andcommitted
feat(aap): In App WAF support for lambda (#7783)
Adds AppSec support for AWS lambda to dd-trace-js by introducing DC handlers that allow the datadog-lambda-js layer to delegate WAF execution to the tracer.
1 parent c6a6201 commit 14496f3

9 files changed

Lines changed: 763 additions & 24 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ module.exports = {
2727
httpClientResponseFinish: dc.channel('apm:http:client:response:finish'),
2828
incomingHttpRequestEnd: dc.channel('dd-trace:incomingHttpRequestEnd'),
2929
incomingHttpRequestStart: dc.channel('dd-trace:incomingHttpRequestStart'),
30+
lambdaStartInvocation: dc.channel('datadog:lambda:start-invocation'),
31+
lambdaEndInvocation: dc.channel('datadog:lambda:end-invocation'),
3032
multerParser: dc.channel('datadog:multer:read:finish'),
3133
mysql2OuterQueryStart: dc.channel('datadog:mysql2:outerquery:start'),
3234
nextBodyParsed: dc.channel('apm:next:body-parsed'),

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const {
1616
fastifyCookieParser,
1717
incomingHttpRequestStart,
1818
incomingHttpRequestEnd,
19+
lambdaStartInvocation,
20+
lambdaEndInvocation,
1921
passportVerify,
2022
passportUser,
2123
expressSession,
@@ -43,6 +45,7 @@ const { isBlocked, block, callBlockDelegation, setTemplates, getBlockingAction }
4345
const { getActiveRequest } = require('./store')
4446
const UserTracking = require('./user_tracking')
4547
const graphql = require('./graphql')
48+
const lambda = require('./lambda')
4649
const rasp = require('./rasp')
4750

4851
const responseAnalyzedSet = new WeakSet()
@@ -99,11 +102,15 @@ function enable (_config) {
99102
stripeCheckoutSessionCreate.subscribe(onStripeCheckoutSessionCreate)
100103
stripePaymentIntentCreate.subscribe(onStripePaymentIntentCreate)
101104
stripeConstructEvent.subscribe(onStripeConstructEvent)
105+
lambdaStartInvocation.subscribe(lambda.onLambdaStartInvocation)
106+
lambdaEndInvocation.subscribe(lambda.onLambdaEndInvocation)
102107

103108
isEnabled = true
104109
config = _config
105110
} catch (err) {
106-
if (!IS_SERVERLESS) {
111+
if (IS_SERVERLESS) {
112+
log.debug('[ASM] Serverless mode: suppressing error log, calling disable()')
113+
} else {
107114
log.error('[ASM] Unable to start AppSec', err)
108115
}
109116

@@ -544,6 +551,8 @@ function disable () {
544551
if (stripeCheckoutSessionCreate.hasSubscribers) stripeCheckoutSessionCreate.unsubscribe(onStripeCheckoutSessionCreate)
545552
if (stripePaymentIntentCreate.hasSubscribers) stripePaymentIntentCreate.unsubscribe(onStripePaymentIntentCreate)
546553
if (stripeConstructEvent.hasSubscribers) stripeConstructEvent.unsubscribe(onStripeConstructEvent)
554+
if (lambdaStartInvocation.hasSubscribers) lambdaStartInvocation.unsubscribe(lambda.onLambdaStartInvocation)
555+
if (lambdaEndInvocation.hasSubscribers) lambdaEndInvocation.unsubscribe(lambda.onLambdaEndInvocation)
547556
}
548557

549558
/**
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use strict'
2+
3+
const { HTTP_CLIENT_IP } = require('../../../../ext/tags')
4+
5+
const log = require('../log')
6+
const addresses = require('./addresses')
7+
const Reporter = require('./reporter')
8+
const waf = require('./waf')
9+
10+
// Tracks spans for which start-invocation has been processed, so that
11+
// end-invocation can gate correctly
12+
const activeInvocations = new WeakSet()
13+
14+
/**
15+
* Maps pre-extracted HTTP data from the Lambda event to WAF addresses,
16+
* runs the WAF, and reports results on the span.
17+
*
18+
* @param {{ span: object, headers: Record<string, string>, method: string, path: string,
19+
* query: Record<string, string | string[]> | undefined, body: string | object | undefined,
20+
* isBase64Encoded: boolean, clientIp: string | undefined,
21+
* pathParams: Record<string, string> | undefined,
22+
* cookies: Record<string, string> | undefined,
23+
* route: string | undefined }} data
24+
*/
25+
function onLambdaStartInvocation (data) {
26+
try {
27+
const { span, headers, method, path, query, body, clientIp, pathParams, cookies } = data
28+
29+
if (!span) {
30+
log.warn('[ASM] No span provided in Lambda start invocation')
31+
return
32+
}
33+
34+
activeInvocations.add(span)
35+
36+
span.setTag('_dd.appsec.enabled', 1)
37+
38+
const persistent = {}
39+
40+
if (path) {
41+
persistent[addresses.HTTP_INCOMING_URL] = path
42+
}
43+
44+
if (method) {
45+
persistent[addresses.HTTP_INCOMING_METHOD] = method
46+
}
47+
48+
if (headers) {
49+
// Cookie header is already stripped by the Lambda layer's event-data-extractor
50+
persistent[addresses.HTTP_INCOMING_HEADERS] = headers
51+
}
52+
53+
if (clientIp) {
54+
span.setTag(HTTP_CLIENT_IP, clientIp)
55+
persistent[addresses.HTTP_CLIENT_IP] = clientIp
56+
}
57+
58+
if (query) {
59+
persistent[addresses.HTTP_INCOMING_QUERY] = query
60+
}
61+
62+
if (body !== undefined && body !== null) {
63+
persistent[addresses.HTTP_INCOMING_BODY] = body
64+
}
65+
66+
if (pathParams) {
67+
persistent[addresses.HTTP_INCOMING_PARAMS] = pathParams
68+
}
69+
70+
if (cookies) {
71+
persistent[addresses.HTTP_INCOMING_COOKIES] = cookies
72+
}
73+
74+
waf.run({ persistent }, span, undefined, span)
75+
} catch (err) {
76+
log.error('[ASM] Error in Lambda start-invocation handler', err)
77+
}
78+
}
79+
80+
/**
81+
* Maps response data to WAF addresses, runs a final WAF pass,
82+
* disposes the WAF context, and finishes the request report.
83+
*
84+
* @param {{ span: object, statusCode: string | undefined,
85+
* responseHeaders: Record<string, string> | undefined }} data
86+
*/
87+
function onLambdaEndInvocation (data) {
88+
try {
89+
const { span, statusCode, responseHeaders } = data
90+
91+
if (!span) {
92+
log.warn('[ASM] No span provided in Lambda end invocation')
93+
return
94+
}
95+
96+
if (!activeInvocations.has(span)) {
97+
return
98+
}
99+
100+
activeInvocations.delete(span)
101+
102+
let hasPersistentData = false
103+
const persistent = {}
104+
105+
if (statusCode) {
106+
persistent[addresses.HTTP_INCOMING_RESPONSE_CODE] = String(statusCode)
107+
hasPersistentData = true
108+
}
109+
110+
if (responseHeaders) {
111+
const filteredHeaders = { ...responseHeaders }
112+
delete filteredHeaders['set-cookie']
113+
persistent[addresses.HTTP_INCOMING_RESPONSE_HEADERS] = filteredHeaders
114+
hasPersistentData = true
115+
}
116+
117+
if (hasPersistentData) {
118+
waf.run({ persistent }, span, undefined, span)
119+
}
120+
121+
waf.disposeContext(span)
122+
123+
Reporter.finishRequest(span, null, {}, undefined, span)
124+
} catch (err) {
125+
log.error('[ASM] Error in Lambda end-invocation handler', err)
126+
}
127+
}
128+
129+
module.exports = {
130+
onLambdaStartInvocation,
131+
onLambdaEndInvocation,
132+
}

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

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,21 @@ function reportWafConfigUpdate (product, rcConfigId, diagnostics, wafVersion) {
318318
}
319319
}
320320

321-
function reportMetrics (metrics, raspRule, req) {
321+
/**
322+
* @param {object} metrics - WAF run metrics
323+
* @param {string} [raspRule] - RASP rule identifier
324+
* @param {object} [req] - Request key (plain object for lambda)
325+
* @param {object} [rootSpan] - Root span (required for lambda)
326+
*/
327+
function reportMetrics (metrics, raspRule, req, rootSpan) {
322328
if (!req) {
323329
req = getActiveRequest()
324330
}
325-
const rootSpan = req && web.root(req)
331+
if (!rootSpan) {
332+
rootSpan = req && web.root(req)
333+
}
326334

327-
if (!rootSpan) return
335+
if (!req || !rootSpan) return
328336

329337
if (metrics.rulesVersion) {
330338
rootSpan.setTag('_dd.appsec.event_rules.version', metrics.rulesVersion)
@@ -353,13 +361,26 @@ function reportTruncationMetrics (rootSpan, metrics) {
353361
}
354362
}
355363

356-
function reportAttack ({ events: attackData, actions }, req) {
364+
// NOTE: `req` in the WAF execution path may be any object, not necessarily
365+
// an HTTP IncomingMessage. In Lambda, it is a plain context key ({}) with no
366+
// HTTP properties. Always guard HTTP-specific property access
367+
// See tests in lambda.spec.js for enforcement.
368+
369+
/**
370+
* @param {{ events: Array, actions: object }} result - WAF result with attack data
371+
* @param {object} [req] - Request key. May be an HTTP IncomingMessage or a plain object (Lambda)
372+
* @param {object} [rootSpan] - Root span (required for lambda)
373+
*/
374+
function reportAttack ({ events: attackData, actions }, req, rootSpan) {
357375
if (!req) {
358376
req = getActiveRequest()
359377
}
360378

361-
const rootSpan = web.root(req)
362-
if (!rootSpan) return
379+
if (!rootSpan) {
380+
rootSpan = web.root(req)
381+
}
382+
383+
if (!req || !rootSpan) return
363384

364385
const spanContext = rootSpan.context()
365386

@@ -494,14 +515,21 @@ function isSchemaAttribute (attribute) {
494515
return attribute.startsWith('_dd.appsec.s.')
495516
}
496517

497-
function reportAttributes (attributes, req) {
518+
/**
519+
* @param {object} [attributes] - WAF result attributes
520+
* @param {object} [req] - Request key (plain object for lambda)
521+
* @param {object} [rootSpan] - Root span (required for lambda)
522+
*/
523+
function reportAttributes (attributes, req, rootSpan) {
498524
if (!attributes) return
499525

500526
if (!req) {
501527
req = getActiveRequest()
502528
}
503529

504-
const rootSpan = web.root(req)
530+
if (!rootSpan) {
531+
rootSpan = web.root(req)
532+
}
505533

506534
if (!rootSpan) return
507535

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

520-
function finishRequest (req, res, storedResponseHeaders, requestBody) {
521-
const rootSpan = web.root(req)
548+
/**
549+
* @param {object} [req] - Request key (null for Lambda)
550+
* @param {object} [res] - Response object (null for lambda)
551+
* @param {object} storedResponseHeaders
552+
* @param {object} [requestBody]
553+
* @param {object} [rootSpan] - Root span (required for lambda)
554+
*/
555+
function finishRequest (req, res, storedResponseHeaders, requestBody, rootSpan) {
556+
if (!rootSpan) {
557+
rootSpan = web.root(req)
558+
}
522559
if (!rootSpan) return
523560

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

609+
if (!req) return
610+
572611
incrementWafRequestsMetric(req)
573612

574613
const tags = rootSpan.context().getTags()

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,19 @@ function removeConfig (configPath) {
110110
}
111111
}
112112

113-
function run (data, req, raspRule) {
113+
// `req` in the WAF execution path may be any object, not necessarily
114+
// an HTTP IncomingMessage (it is a plain object for lambda).
115+
// Always guard HTTP-specific property access.
116+
// @see packages/dd-trace/test/appsec/lambda.spec.js
117+
118+
/**
119+
* @param {object} data - WAF address data ({ persistent, ephemeral })
120+
* @param {object} req - Request key for WAF context lookup. May be an HTTP
121+
* IncomingMessage or a plain object (Lambda invocation key).
122+
* @param {string} [raspRule] - RASP rule identifier
123+
* @param {object} [rootSpan] - Root span to tag (required for Lambda)
124+
*/
125+
function run (data, req, raspRule, rootSpan) {
114126
if (!req) {
115127
req = getActiveRequest()
116128
if (!req) {
@@ -120,12 +132,12 @@ function run (data, req, raspRule) {
120132
}
121133

122134
const wafContext = waf.wafManager.getWAFContext(req)
123-
const result = wafContext.run(data, raspRule, req)
135+
const result = wafContext.run(data, raspRule, req, rootSpan)
124136

125137
if (result?.keep) {
126138
if (limiter.isAllowed()) {
127-
const rootSpan = web.root(req)
128-
keepTrace(rootSpan, ASM)
139+
const span = rootSpan || web.root(req)
140+
keepTrace(span, ASM)
129141
} else {
130142
updateRateLimitedMetric(req)
131143
}

packages/dd-trace/src/appsec/waf/waf_context_wrapper.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class WAFContextWrapper {
3636
}
3737
}
3838

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

158158
if (ruleTriggered) {
159-
Reporter.reportAttack(result, req)
159+
Reporter.reportAttack(result, req, rootSpan)
160160
}
161161

162-
Reporter.reportAttributes(result.attributes, req)
162+
Reporter.reportAttributes(result.attributes, req, rootSpan)
163163

164164
return result
165165
} catch (err) {
@@ -171,7 +171,7 @@ class WAFContextWrapper {
171171
wafRunFinished.publish({ payload })
172172
}
173173

174-
Reporter.reportMetrics(metrics, raspRule, req)
174+
Reporter.reportMetrics(metrics, raspRule, req, rootSpan)
175175
}
176176
}
177177

0 commit comments

Comments
 (0)