From a9fffb5ac314d062a5c32091112318f845641ede Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:42:55 +0100 Subject: [PATCH 01/21] Remove unused native-appsec binaries + native-appsec version pinned in dd-trace-js --- Dockerfile | 12 +++++++++++- scripts/move_ddtrace_dependency.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 47b50e062..b5bb9ce9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,12 +32,13 @@ fs.writeFileSync( EOF RUN cp ./src/handler.mjs /nodejs/node_modules/datadog-lambda-js -RUN rm -rf node_modules # Move dd-trace from devDependencies to production dependencies # That way it is included in our layer, while keeping it an optional dependency for npm RUN node ./scripts/move_ddtrace_dependency.js "$(cat package.json)" > package-new.json RUN mv package-new.json package.json +RUN rm -rf node_modules + # Install dependencies RUN yarn install --production=true --ignore-optional --ignore-engines # Copy the dependencies to the modules folder @@ -62,6 +63,15 @@ RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-120.node RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-131.node RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-141.node +# Remove unused @datadog/native-appsec prebuilds for non-Lambda platforms. +# Lambda runs on Amazon Linux 2 (glibc), on x64 or arm64. +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/darwin-arm64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/darwin-x64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/win32-ia32 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/win32-x64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/linuxmusl-arm64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/linuxmusl-x64 + # Remove heavy files from @opentelemetry/api which aren't used in a lambda environment. # TODO: Create a completely separate Datadog scoped package for OpenTelemetry instead. RUN rm -rf /nodejs/node_modules/@opentelemetry/api/build/esm diff --git a/scripts/move_ddtrace_dependency.js b/scripts/move_ddtrace_dependency.js index 1e2f904fc..a27c28f33 100755 --- a/scripts/move_ddtrace_dependency.js +++ b/scripts/move_ddtrace_dependency.js @@ -1,4 +1,6 @@ // Moves the dd-trace dependency from devDependencies to dependencies within package.json. +// Also promotes selected dd-trace optionalDependencies to direct dependencies so they +// survive `yarn install --production=true --ignore-optional`. // This is used when building the Layer // USAGE: ./move_dd_trace_dependency.js "$(cat package.json)" > package.json @@ -10,6 +12,8 @@ moveDependency('@datadog/pprof') moveDependency('@opentelemetry/api') moveDependency('@opentelemetry/api-logs') +addOptionalFromDdTrace('@datadog/native-appsec') + console.log(JSON.stringify(file, null, 2)); function moveDependency (name) { @@ -17,3 +21,16 @@ function moveDependency (name) { delete file.devDependencies[name]; file.dependencies[name] = ddTraceVersion; } + +function addOptionalFromDdTrace (name) { + try { + const ddTracePkg = require('dd-trace/package.json') + const version = ddTracePkg.optionalDependencies?.[name] + if (version) { + file.dependencies[name] = version + } + } catch { + // dd-trace not installed yet; skip + } +} + From 6f6644eda67e03df83aece0d2be39d69bc55b15d Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:44:45 +0100 Subject: [PATCH 02/21] Extract event data and pass it over dc --- src/appsec/event-data-extractor.spec.ts | 240 ++++++++++++++++++++++++ src/appsec/event-data-extractor.ts | 229 ++++++++++++++++++++++ src/appsec/index.spec.ts | 168 +++++++++++++++++ src/appsec/index.ts | 47 +++++ src/trace/listener.ts | 11 ++ 5 files changed, 695 insertions(+) create mode 100644 src/appsec/event-data-extractor.spec.ts create mode 100644 src/appsec/event-data-extractor.ts create mode 100644 src/appsec/index.spec.ts create mode 100644 src/appsec/index.ts diff --git a/src/appsec/event-data-extractor.spec.ts b/src/appsec/event-data-extractor.spec.ts new file mode 100644 index 000000000..1458f7aa8 --- /dev/null +++ b/src/appsec/event-data-extractor.spec.ts @@ -0,0 +1,240 @@ +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +describe("extractHTTPDataFromEvent", () => { + describe("non-HTTP events", () => { + it("should return undefined for SQS events", () => { + const event = { Records: [{ eventSource: "aws:sqs", body: "test" }] }; + expect(extractHTTPDataFromEvent(event)).toBeUndefined(); + }); + + it("should return undefined for S3 events", () => { + const event = { Records: [{ s3: { bucket: { name: "test" } } }] }; + expect(extractHTTPDataFromEvent(event)).toBeUndefined(); + }); + + it("should return undefined for empty events", () => { + expect(extractHTTPDataFromEvent({})).toBeUndefined(); + }); + }); + + describe("API Gateway v1", () => { + const baseEvent = { + httpMethod: "GET", + path: "/my/path", + resource: "/my/{param}", + headers: { Host: "example.com", Cookie: "session=abc; lang=en" }, + multiValueHeaders: null, + queryStringParameters: { foo: "bar" }, + multiValueQueryStringParameters: null, + pathParameters: { param: "123" }, + body: null, + isBase64Encoded: false, + requestContext: { + stage: "prod", + path: "/prod/my/path", + identity: { sourceIp: "1.2.3.4" }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/prod/my/path"); + expect(result!.clientIp).toBe("1.2.3.4"); + expect(result!.route).toBe("/my/{param}"); + expect(result!.pathParams).toEqual({ param: "123" }); + expect(result!.query).toEqual({ foo: "bar" }); + expect(result!.isBase64Encoded).toBe(false); + }); + + it("should separate cookies from headers", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.headers.cookie).toBeUndefined(); + expect(result!.cookies).toEqual({ session: "abc", lang: "en" }); + }); + + it("should normalize header names to lowercase", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.headers.host).toBe("example.com"); + }); + + it("should decode base64 body", () => { + const event = { + ...baseEvent, + body: Buffer.from('{"key":"value"}').toString("base64"), + isBase64Encoded: true, + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toEqual({ key: "value" }); + expect(result!.isBase64Encoded).toBe(true); + }); + + it("should parse JSON body when not base64 encoded", () => { + const event = { + ...baseEvent, + body: '{"key":"value"}', + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toEqual({ key: "value" }); + }); + + it("should return raw string body when not JSON", () => { + const event = { + ...baseEvent, + body: "plain text body", + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toBe("plain text body"); + }); + + it("should merge multi-value query params", () => { + const event = { + ...baseEvent, + queryStringParameters: { foo: "bar" }, + multiValueQueryStringParameters: { foo: ["bar", "baz"], single: ["one"] }, + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.query).toEqual({ foo: ["bar", "baz"], single: "one" }); + }); + }); + + describe("API Gateway v2", () => { + const baseEvent = { + version: "2.0", + rawPath: "/my/path", + rawQueryString: "foo=bar", + headers: { host: "example.com" }, + queryStringParameters: { foo: "bar" }, + pathParameters: { id: "456" }, + body: null, + isBase64Encoded: false, + cookies: ["session=abc", "lang=en"], + routeKey: "GET /my/{id}", + requestContext: { + http: { + method: "POST", + path: "/my/path", + sourceIp: "5.6.7.8", + }, + domainName: "api.example.com", + apiId: "abc123", + stage: "$default", + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("POST"); + expect(result!.path).toBe("/my/path"); + expect(result!.clientIp).toBe("5.6.7.8"); + expect(result!.route).toBe("/my/{id}"); + expect(result!.pathParams).toEqual({ id: "456" }); + }); + + it("should parse cookies from the cookies array", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.cookies).toEqual({ session: "abc", lang: "en" }); + }); + + it("should extract route from routeKey", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBe("/my/{id}"); + }); + }); + + describe("ALB", () => { + const baseEvent = { + httpMethod: "GET", + path: "/alb/path", + headers: { + host: "example.com", + "x-forwarded-for": "9.8.7.6, 10.0.0.1", + cookie: "token=xyz", + }, + queryStringParameters: { key: "val" }, + body: null, + isBase64Encoded: false, + requestContext: { + elb: { + targetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/my-tg/abc", + }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/alb/path"); + }); + + it("should extract client IP from x-forwarded-for", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.clientIp).toBe("9.8.7.6"); + }); + + it("should parse cookies from the cookie header", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.cookies).toEqual({ token: "xyz" }); + expect(result!.headers.cookie).toBeUndefined(); + }); + + it("should not have route or pathParams", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBeUndefined(); + expect(result!.pathParams).toBeUndefined(); + }); + }); + + describe("Lambda Function URL", () => { + const baseEvent = { + version: "2.0", + rawPath: "/url/path", + rawQueryString: "", + headers: { host: "abc123.lambda-url.us-east-1.on.aws" }, + queryStringParameters: null, + body: null, + isBase64Encoded: false, + cookies: ["token=xyz"], + requestContext: { + domainName: "abc123.lambda-url.us-east-1.on.aws", + http: { + method: "GET", + path: "/url/path", + sourceIp: "11.12.13.14", + }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/url/path"); + expect(result!.clientIp).toBe("11.12.13.14"); + }); + + it("should parse cookies from the cookies array", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.cookies).toEqual({ token: "xyz" }); + }); + + it("should not have route", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBeUndefined(); + }); + }); +}); diff --git a/src/appsec/event-data-extractor.ts b/src/appsec/event-data-extractor.ts new file mode 100644 index 000000000..d4b7567d8 --- /dev/null +++ b/src/appsec/event-data-extractor.ts @@ -0,0 +1,229 @@ +import * as eventType from "../utils/event-type-guards"; + +export interface ExtractedHTTPData { + headers: Record; + method: string; + path: string; + query?: Record; + body?: string | object; + isBase64Encoded: boolean; + clientIp?: string; + pathParams?: Record; + cookies?: Record; + route?: string; +} + +export function extractHTTPDataFromEvent(event: any): ExtractedHTTPData | undefined { + if (eventType.isLambdaUrlEvent(event)) { + return extractFromLambdaUrl(event); + } + + if (eventType.isAPIGatewayEvent(event)) { + return extractFromApiGatewayV1(event); + } + + if (eventType.isAPIGatewayEventV2(event)) { + return extractFromApiGatewayV2(event); + } + + if (eventType.isALBEvent(event)) { + return extractFromALB(event); + } + + return undefined; +} + +function extractFromApiGatewayV1(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers, event.multiValueHeaders); + const { cookies, headersNoCookies } = separateCookies(headers); + + return { + headers: headersNoCookies, + method: event.httpMethod || "", + path: event.requestContext?.path || event.path || "/", + query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.identity?.sourceIp, + pathParams: event.pathParameters || undefined, + cookies, + route: event.resource, + }; +} + +function extractFromApiGatewayV2(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers); + const { headersNoCookies } = separateCookies(headers); + + const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); + + let route: string | undefined; + if (event.routeKey) { + const parts = event.routeKey.split(" "); + route = parts.length > 1 ? parts[parts.length - 1] : parts[0]; + } + + return { + headers: headersNoCookies, + method: event.requestContext?.http?.method || "", + path: event.rawPath || event.requestContext?.http?.path || "/", + query: event.queryStringParameters || undefined, + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.http?.sourceIp, + pathParams: event.pathParameters || undefined, + cookies, + route, + }; +} + +function extractFromALB(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers, event.multiValueHeaders); + const { cookies, headersNoCookies } = separateCookies(headers); + + const forwardedFor = headers["x-forwarded-for"]; + const clientIp = forwardedFor ? forwardedFor.split(",")[0].trim() : undefined; + + return { + headers: headersNoCookies, + method: event.httpMethod || "", + path: event.path || "/", + query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp, + cookies, + }; +} + +function extractFromLambdaUrl(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers); + const { headersNoCookies } = separateCookies(headers); + + const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); + + return { + headers: headersNoCookies, + method: event.requestContext?.http?.method || "", + path: event.rawPath || event.requestContext?.http?.path || "/", + query: event.queryStringParameters || undefined, + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.http?.sourceIp, + cookies, + }; +} + +function normalizeHeaders( + headers?: Record, + multiValueHeaders?: Record, +): Record { + if (!headers && !multiValueHeaders) return {}; + + const result: Record = {}; + + if (multiValueHeaders) { + for (const [key, values] of Object.entries(multiValueHeaders)) { + if (values && values.length > 0) { + result[key.toLowerCase()] = values.join(", "); + } + } + } + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (!(lowerKey in result) && value !== undefined) { + result[lowerKey] = value; + } + } + } + + return result; +} + +function separateCookies(headers: Record): { + cookies: Record | undefined; + headersNoCookies: Record; +} { + const cookies = parseCookieHeader(headers.cookie); + const headersNoCookies = { ...headers }; + delete headersNoCookies.cookie; + return { cookies, headersNoCookies }; +} + +function parseCookieHeader(cookieHeader: string | undefined): Record | undefined { + if (!cookieHeader) return undefined; + + const result: Record = {}; + for (const pair of cookieHeader.split(";")) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) continue; + const name = pair.substring(0, eqIdx).trim(); + const value = pair.substring(eqIdx + 1).trim(); + if (name) { + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function parseCookieArray(cookies: string[] | undefined): Record | undefined { + if (!cookies || !Array.isArray(cookies) || cookies.length === 0) return undefined; + + const result: Record = {}; + for (const cookie of cookies) { + const eqIdx = cookie.indexOf("="); + if (eqIdx === -1) continue; + const name = cookie.substring(0, eqIdx).trim(); + const value = cookie.substring(eqIdx + 1).trim(); + if (name) { + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function mergeQueryParams( + single?: Record | null, + multi?: Record | null, +): Record | undefined { + if (!single && !multi) return undefined; + + const result: Record = {}; + + if (multi) { + for (const [key, values] of Object.entries(multi)) { + if (values && values.length > 0) { + result[key] = values.length === 1 ? values[0] : values; + } + } + } else if (single) { + for (const [key, value] of Object.entries(single)) { + if (value !== undefined && value !== null) { + result[key] = value; + } + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function decodeBody(body: string | undefined | null, isBase64Encoded: boolean): string | object | undefined { + if (body === undefined || body === null) return undefined; + + let decoded = body; + if (isBase64Encoded) { + try { + decoded = Buffer.from(body, "base64").toString("utf-8"); + } catch { + return body; + } + } + + try { + return JSON.parse(decoded); + } catch { + return decoded; + } +} diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts new file mode 100644 index 000000000..f1d609d44 --- /dev/null +++ b/src/appsec/index.spec.ts @@ -0,0 +1,168 @@ +const mockPublish = jest.fn(); + +jest.mock("dc-polyfill", () => ({ + channel: jest.fn(() => ({ + publish: mockPublish, + })), +})); + +import { initAppsec, processAppsecRequest, processAppsecResponse } from "./index"; + +jest.mock("./event-data-extractor", () => ({ + extractHTTPDataFromEvent: jest.fn(), +})); + +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +const mockExtract = extractHTTPDataFromEvent as jest.MockedFunction; + +describe("AppSec orchestrator", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe("initAppsec", () => { + it("should enable when DD_APPSEC_ENABLED is true", () => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: { host: "example.com" }, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + + it("should enable when DD_APPSEC_ENABLED is 1", () => { + process.env.DD_APPSEC_ENABLED = "1"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: {}, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + + it("should not enable when DD_APPSEC_ENABLED is not set", () => { + delete process.env.DD_APPSEC_ENABLED; + initAppsec(); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should not enable when DD_APPSEC_ENABLED is false", () => { + process.env.DD_APPSEC_ENABLED = "false"; + initAppsec(); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + }); + + describe("processAppSecRequest", () => { + beforeEach(() => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + }); + + it("should not publish when span is falsy", () => { + processAppsecRequest({}, null); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should not publish when event is not an HTTP trigger", () => { + mockExtract.mockReturnValue(undefined as any); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should publish extracted HTTP data to the start-invocation channel", () => { + const span = { setTag: jest.fn() }; + const httpData = { + headers: { host: "example.com" }, + method: "POST", + path: "/api/test", + query: { foo: "bar" }, + body: { key: "value" }, + isBase64Encoded: false, + clientIp: "1.2.3.4", + pathParams: { id: "123" }, + cookies: { session: "abc" }, + route: "/api/{id}", + }; + mockExtract.mockReturnValue(httpData); + + processAppsecRequest({}, span); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + headers: httpData.headers, + method: httpData.method, + path: httpData.path, + query: httpData.query, + body: httpData.body, + isBase64Encoded: httpData.isBase64Encoded, + clientIp: httpData.clientIp, + pathParams: httpData.pathParams, + cookies: httpData.cookies, + route: httpData.route, + }); + }); + }); + + describe("processAppSecResponse", () => { + beforeEach(() => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + }); + + it("should not publish when span is falsy", () => { + processAppsecResponse(null, "200"); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should publish response data to the end-invocation channel", () => { + const span = { setTag: jest.fn() }; + + processAppsecResponse(span, "200", { "content-type": "application/json" }); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + statusCode: "200", + responseHeaders: { "content-type": "application/json" }, + }); + }); + + it("should publish with undefined statusCode and headers", () => { + const span = { setTag: jest.fn() }; + + processAppsecResponse(span); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + statusCode: undefined, + responseHeaders: undefined, + }); + }); + }); +}); diff --git a/src/appsec/index.ts b/src/appsec/index.ts new file mode 100644 index 000000000..83019b5d2 --- /dev/null +++ b/src/appsec/index.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const dc = require("dc-polyfill"); + +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +const startInvocationChannel = dc.channel("datadog:lambda:start-invocation"); +const endInvocationChannel = dc.channel("datadog:lambda:end-invocation"); + +let enabled = false; + +export function initAppsec(): void { + const envValue = process.env.DD_APPSEC_ENABLED; + enabled = envValue === "true" || envValue === "1"; +} + +export function processAppsecRequest(event: any, span: any): void { + if (!enabled || !span || !startInvocationChannel.hasSubscribers) return; + + const httpData = extractHTTPDataFromEvent(event); + if (!httpData ) { + return; + } + + startInvocationChannel.publish({ + span, + headers: httpData.headers, + method: httpData.method, + path: httpData.path, + query: httpData.query, + body: httpData.body, + isBase64Encoded: httpData.isBase64Encoded, + clientIp: httpData.clientIp, + pathParams: httpData.pathParams, + cookies: httpData.cookies, + route: httpData.route, + }); +} + +export function processAppsecResponse(span: any, statusCode?: string, responseHeaders?: Record): void { + if (!enabled || !span || !endInvocationChannel.hasSubscribers) return; + + endInvocationChannel.publish({ + span, + statusCode, + responseHeaders, + }); +} diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 19459e24e..3fcce153f 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -28,6 +28,8 @@ import { import { XrayService } from "./xray-service"; import { AUTHORIZING_REQUEST_ID_HEADER } from "./context/extractors/http"; import { getSpanPointerAttributes, SpanPointerAttributes } from "../utils/span-pointers"; +import { initAppsec, processAppsecRequest, processAppsecResponse } from "../appsec"; + export type TraceExtractor = (event: any, context: Context) => Promise | TraceContext; export interface TraceConfig { @@ -115,6 +117,8 @@ export class TraceListener { } public async onStartInvocation(event: any, context: Context) { + initAppsec(); + const tracerInitialized = this.tracerWrapper.isTracerAvailable; if (this.config.injectLogContext) { patchConsole(console, this.contextService); @@ -179,6 +183,9 @@ export class TraceListener { // so we won't crash user code. if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); + + processAppsecResponse(event, this.tracerWrapper.currentSpan); + if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); tagObject( @@ -213,6 +220,10 @@ export class TraceListener { // Always clear the tree to prevent memory leaks, even if we skip span creation clearTraceTree(); } + const responseStatusCode = result?.statusCode?.toString(); + const responseHeaders = result?.headers as Record | undefined; + processAppsecResponse(this.tracerWrapper.currentSpan, responseStatusCode, responseHeaders); + if (this.triggerTags) { const statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction); From 2c02b6e0393febbafb92e905cb1d90c2c405bb14 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 08:03:47 +0100 Subject: [PATCH 03/21] Fix linting issues --- src/appsec/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appsec/index.ts b/src/appsec/index.ts index 83019b5d2..828cc0319 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires +// tslint:disable-next-line:no-var-requires const dc = require("dc-polyfill"); import { extractHTTPDataFromEvent } from "./event-data-extractor"; From 9d544320d77692cc725cc1f6feb059b02317ac81 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:10:59 +0100 Subject: [PATCH 04/21] Fix formatting issue --- src/appsec/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appsec/index.ts b/src/appsec/index.ts index 828cc0319..c6803f504 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -17,7 +17,7 @@ export function processAppsecRequest(event: any, span: any): void { if (!enabled || !span || !startInvocationChannel.hasSubscribers) return; const httpData = extractHTTPDataFromEvent(event); - if (!httpData ) { + if (!httpData) { return; } From ccda597efe26bb073284ec440924fade2ea953e7 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:26:27 +0100 Subject: [PATCH 05/21] Fix test mock --- src/appsec/index.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index f1d609d44..12481dda6 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -3,6 +3,7 @@ const mockPublish = jest.fn(); jest.mock("dc-polyfill", () => ({ channel: jest.fn(() => ({ publish: mockPublish, + hasSubscribers: true, })), })); From 84f05f21d6fc71df8004774929368e8030fa4875 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 17:44:09 +0200 Subject: [PATCH 06/21] Fix typo. Add test for Appsec integration in listener --- src/trace/listener.spec.ts | 96 ++++++++++++++++++++++++++++++++++++++ src/trace/listener.ts | 2 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/trace/listener.spec.ts b/src/trace/listener.spec.ts index 27e9b664b..bf368ea63 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -12,6 +12,16 @@ import { } from "./context/extractor"; import { TracerWrapper } from "./tracer-wrapper"; +const mockInitAppsec = jest.fn(); +const mockProcessAppsecRequest = jest.fn(); +const mockProcessAppsecResponse = jest.fn(); + +jest.mock("../appsec", () => ({ + initAppsec: (...args: any[]) => mockInitAppsec(...args), + processAppsecRequest: (...args: any[]) => mockProcessAppsecRequest(...args), + processAppsecResponse: (...args: any[]) => mockProcessAppsecResponse(...args), +})); + const mockController: { mockSpanContext?: any; mockSpanContextWrapper?: any; @@ -87,6 +97,9 @@ describe("TraceListener", () => { }; beforeEach(() => { wrapSpy.mockClear(); + mockInitAppsec.mockClear(); + mockProcessAppsecRequest.mockClear(); + mockProcessAppsecResponse.mockClear(); mockController.mockSpanContext = undefined; mockController.mockSpanContextWrapper = undefined; mockController.mockTraceSource = undefined; @@ -605,4 +618,87 @@ describe("TraceListener", () => { currentSpanSpy.mockRestore(); } }); + + describe("AppSec integration", () => { + it("calls initAppsec on start invocation", async () => { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + + expect(mockInitAppsec).toHaveBeenCalledTimes(1); + }); + + it("calls processAppsecRequest with event and span during onEndingInvocation", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + const event = { httpMethod: "GET", path: "/test" }; + await listener.onStartInvocation(event, context as any); + listener.onEndingInvocation(event, {}, false); + + expect(mockProcessAppsecRequest).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecRequest).toHaveBeenCalledWith(event, mockSpan); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecResponse with span, statusCode, and headers during onEndingInvocation", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + const event = {}; + const result = { statusCode: 200, headers: { "content-type": "application/json" } }; + await listener.onStartInvocation(event, context as any); + listener.onEndingInvocation(event, result, false); + + expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, "200", { "content-type": "application/json" }); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecResponse with undefined statusCode and headers when result has none", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + listener.onEndingInvocation({}, {}, false); + + expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, undefined, undefined); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecRequest before processAppsecResponse", async () => { + const callOrder: string[] = []; + mockProcessAppsecRequest.mockImplementation(() => callOrder.push("request")); + mockProcessAppsecResponse.mockImplementation(() => callOrder.push("response")); + + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + listener.onEndingInvocation({}, {}, false); + + expect(callOrder).toEqual(["request", "response"]); + } finally { + currentSpanSpy.mockRestore(); + } + }); + }); }); diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 3fcce153f..1adc7a8b1 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -184,7 +184,7 @@ export class TraceListener { if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); - processAppsecResponse(event, this.tracerWrapper.currentSpan); + processAppsecRequest(event, this.tracerWrapper.currentSpan); if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); From 896f7233ec84e13f8d19a13bc2956a62c47fcee7 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 17:57:50 +0200 Subject: [PATCH 07/21] Avoid including cookies and route when they are undefined --- src/appsec/event-data-extractor.spec.ts | 38 +++++++++++++++++++++++++ src/appsec/event-data-extractor.ts | 35 ++++++++++++++++------- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/appsec/event-data-extractor.spec.ts b/src/appsec/event-data-extractor.spec.ts index 1458f7aa8..597911425 100644 --- a/src/appsec/event-data-extractor.spec.ts +++ b/src/appsec/event-data-extractor.spec.ts @@ -94,6 +94,23 @@ describe("extractHTTPDataFromEvent", () => { expect(result!.body).toBe("plain text body"); }); + it("should not include cookies when cookie header is absent", () => { + const event = { + ...baseEvent, + headers: { Host: "example.com" }, + }; + const result = extractHTTPDataFromEvent(event); + expect(result!.cookies).toBeUndefined(); + expect("cookies" in result!).toBe(false); + }); + + it("should not include route when resource is empty string", () => { + const event = { ...baseEvent, resource: "" }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + it("should merge multi-value query params", () => { const event = { ...baseEvent, @@ -151,6 +168,27 @@ describe("extractHTTPDataFromEvent", () => { const result = extractHTTPDataFromEvent(baseEvent); expect(result!.route).toBe("/my/{id}"); }); + + it("should not include route when routeKey is absent", () => { + const event = { ...baseEvent, routeKey: undefined }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + + it("should not include route when routeKey produces an empty string", () => { + const event = { ...baseEvent, routeKey: "" }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + + it("should not include cookies when cookies array is absent", () => { + const event = { ...baseEvent, cookies: undefined }; + const result = extractHTTPDataFromEvent(event); + expect(result!.cookies).toBeUndefined(); + expect("cookies" in result!).toBe(false); + }); }); describe("ALB", () => { diff --git a/src/appsec/event-data-extractor.ts b/src/appsec/event-data-extractor.ts index d4b7567d8..d9b453216 100644 --- a/src/appsec/event-data-extractor.ts +++ b/src/appsec/event-data-extractor.ts @@ -37,7 +37,7 @@ function extractFromApiGatewayV1(event: any): ExtractedHTTPData { const headers = normalizeHeaders(event.headers, event.multiValueHeaders); const { cookies, headersNoCookies } = separateCookies(headers); - return { + const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.httpMethod || "", path: event.requestContext?.path || event.path || "/", @@ -46,9 +46,12 @@ function extractFromApiGatewayV1(event: any): ExtractedHTTPData { isBase64Encoded: !!event.isBase64Encoded, clientIp: event.requestContext?.identity?.sourceIp, pathParams: event.pathParameters || undefined, - cookies, - route: event.resource, }; + + if (cookies) result.cookies = cookies; + if (event.resource) result.route = event.resource; + + return result; } function extractFromApiGatewayV2(event: any): ExtractedHTTPData { @@ -60,10 +63,11 @@ function extractFromApiGatewayV2(event: any): ExtractedHTTPData { let route: string | undefined; if (event.routeKey) { const parts = event.routeKey.split(" "); - route = parts.length > 1 ? parts[parts.length - 1] : parts[0]; + const candidate = parts.length > 1 ? parts[parts.length - 1] : parts[0]; + if (candidate) route = candidate; } - return { + const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.requestContext?.http?.method || "", path: event.rawPath || event.requestContext?.http?.path || "/", @@ -72,9 +76,12 @@ function extractFromApiGatewayV2(event: any): ExtractedHTTPData { isBase64Encoded: !!event.isBase64Encoded, clientIp: event.requestContext?.http?.sourceIp, pathParams: event.pathParameters || undefined, - cookies, - route, }; + + if (cookies) result.cookies = cookies; + if (route) result.route = route; + + return result; } function extractFromALB(event: any): ExtractedHTTPData { @@ -84,7 +91,7 @@ function extractFromALB(event: any): ExtractedHTTPData { const forwardedFor = headers["x-forwarded-for"]; const clientIp = forwardedFor ? forwardedFor.split(",")[0].trim() : undefined; - return { + const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.httpMethod || "", path: event.path || "/", @@ -92,8 +99,11 @@ function extractFromALB(event: any): ExtractedHTTPData { body: decodeBody(event.body, event.isBase64Encoded), isBase64Encoded: !!event.isBase64Encoded, clientIp, - cookies, }; + + if (cookies) result.cookies = cookies; + + return result; } function extractFromLambdaUrl(event: any): ExtractedHTTPData { @@ -102,7 +112,7 @@ function extractFromLambdaUrl(event: any): ExtractedHTTPData { const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); - return { + const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.requestContext?.http?.method || "", path: event.rawPath || event.requestContext?.http?.path || "/", @@ -110,8 +120,11 @@ function extractFromLambdaUrl(event: any): ExtractedHTTPData { body: decodeBody(event.body, event.isBase64Encoded), isBase64Encoded: !!event.isBase64Encoded, clientIp: event.requestContext?.http?.sourceIp, - cookies, }; + + if (cookies) result.cookies = cookies; + + return result; } function normalizeHeaders( From b0acc36b9e61a21acc960b973c29637b7eb0c9cb Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:03:56 +0200 Subject: [PATCH 08/21] Rework for native-appsec package dependency --- package.json | 1 + scripts/move_ddtrace_dependency.js | 15 +-------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 4bec922b3..5473b1550 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "devDependencies": { "@aws-sdk/client-kms": "^3.366.0", "@aws-sdk/client-secrets-manager": "^3.721.0", + "@datadog/native-appsec": "*", "@datadog/pprof": "5.14.4", "@opentelemetry/api": ">=1.0.0 <1.10.0", "@opentelemetry/api-logs": "<1.0.0", diff --git a/scripts/move_ddtrace_dependency.js b/scripts/move_ddtrace_dependency.js index a27c28f33..0b0427743 100755 --- a/scripts/move_ddtrace_dependency.js +++ b/scripts/move_ddtrace_dependency.js @@ -8,12 +8,11 @@ const file = JSON.parse(process.argv[2]); moveDependency('dd-trace') +moveDependency('@datadog/native-appsec') moveDependency('@datadog/pprof') moveDependency('@opentelemetry/api') moveDependency('@opentelemetry/api-logs') -addOptionalFromDdTrace('@datadog/native-appsec') - console.log(JSON.stringify(file, null, 2)); function moveDependency (name) { @@ -22,15 +21,3 @@ function moveDependency (name) { file.dependencies[name] = ddTraceVersion; } -function addOptionalFromDdTrace (name) { - try { - const ddTracePkg = require('dd-trace/package.json') - const version = ddTracePkg.optionalDependencies?.[name] - if (version) { - file.dependencies[name] = version - } - } catch { - // dd-trace not installed yet; skip - } -} - From 3241d3e2cf3c53dc92407070a1ee04976e236c06 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:04:46 +0200 Subject: [PATCH 09/21] Clean format --- scripts/move_ddtrace_dependency.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/move_ddtrace_dependency.js b/scripts/move_ddtrace_dependency.js index 0b0427743..13d11d90e 100755 --- a/scripts/move_ddtrace_dependency.js +++ b/scripts/move_ddtrace_dependency.js @@ -1,6 +1,4 @@ // Moves the dd-trace dependency from devDependencies to dependencies within package.json. -// Also promotes selected dd-trace optionalDependencies to direct dependencies so they -// survive `yarn install --production=true --ignore-optional`. // This is used when building the Layer // USAGE: ./move_dd_trace_dependency.js "$(cat package.json)" > package.json @@ -20,4 +18,3 @@ function moveDependency (name) { delete file.devDependencies[name]; file.dependencies[name] = ddTraceVersion; } - From 7553eb69928d5fb386188cd658b734c6787eaeef Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:19:56 +0200 Subject: [PATCH 10/21] Updated yarn.lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index e8fbf0afd..5090dd787 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1211,7 +1211,7 @@ resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.9.4.tgz#3d39c3561fa11a702c0e5e7819c83f4e03c07b78" integrity sha512-55wHHAuhHOOrBGoWV+fRuEDypN6PMJZq4LTOwExnhoDMvVcZKtqYT05xea/toRs0o75+A1IeNAQHsRLbJMf5oA== -"@datadog/native-appsec@11.0.1": +"@datadog/native-appsec@*", "@datadog/native-appsec@11.0.1": version "11.0.1" resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-11.0.1.tgz#8b545a9d968131d9cd7b43fd9594228dfcc18f3c" integrity sha512-Y/XfknUmmJcw4hhQVhqzgdQvfjy+EGmXuUBgtVkI1r+/qS00egYu+wD/x7pOvjdbZNqN96znVszAnXvDQAzMDQ== From a7f307ea61bf5930e96b49b08be6abc95e200675 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 16 Jun 2026 22:54:25 +0200 Subject: [PATCH 11/21] Case insensitive enablement env var --- src/appsec/index.spec.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/appsec/index.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index 12481dda6..3b7577a33 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -62,6 +62,38 @@ describe("AppSec orchestrator", () => { expect(mockPublish).toHaveBeenCalled(); }); + it("should enable when DD_APPSEC_ENABLED is TRUE (uppercase)", () => { + process.env.DD_APPSEC_ENABLED = "TRUE"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: {}, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + + it("should enable when DD_APPSEC_ENABLED is True (mixed case)", () => { + process.env.DD_APPSEC_ENABLED = "True"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: {}, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + it("should not enable when DD_APPSEC_ENABLED is not set", () => { delete process.env.DD_APPSEC_ENABLED; initAppsec(); @@ -77,6 +109,14 @@ describe("AppSec orchestrator", () => { processAppsecRequest({}, {}); expect(mockPublish).not.toHaveBeenCalled(); }); + + it("should not enable when DD_APPSEC_ENABLED is FALSE (uppercase)", () => { + process.env.DD_APPSEC_ENABLED = "FALSE"; + initAppsec(); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); }); describe("processAppSecRequest", () => { diff --git a/src/appsec/index.ts b/src/appsec/index.ts index c6803f504..ccd3bd443 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -9,7 +9,7 @@ const endInvocationChannel = dc.channel("datadog:lambda:end-invocation"); let enabled = false; export function initAppsec(): void { - const envValue = process.env.DD_APPSEC_ENABLED; + const envValue = process.env.DD_APPSEC_ENABLED?.toLowerCase(); enabled = envValue === "true" || envValue === "1"; } From 52ced8917efea13c369cdd19447e1882c91cffc6 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 30 Jun 2026 07:14:26 +0200 Subject: [PATCH 12/21] Fix Appsec reported path for APIGW v1 --- src/appsec/event-data-extractor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appsec/event-data-extractor.ts b/src/appsec/event-data-extractor.ts index d9b453216..880a1b372 100644 --- a/src/appsec/event-data-extractor.ts +++ b/src/appsec/event-data-extractor.ts @@ -40,7 +40,7 @@ function extractFromApiGatewayV1(event: any): ExtractedHTTPData { const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.httpMethod || "", - path: event.requestContext?.path || event.path || "/", + path: event.path || event.requestContext?.path || "/", query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), body: decodeBody(event.body, event.isBase64Encoded), isBase64Encoded: !!event.isBase64Encoded, From 87cab5921275da88767be83e3332c02cbb6f4963 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 30 Jun 2026 07:14:53 +0200 Subject: [PATCH 13/21] Add tag for unsupported event type --- src/appsec/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/appsec/index.ts b/src/appsec/index.ts index ccd3bd443..199d7afa2 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -18,6 +18,7 @@ export function processAppsecRequest(event: any, span: any): void { const httpData = extractHTTPDataFromEvent(event); if (!httpData) { + span.setTag("_dd.appsec.unsupported_event_type", 1); return; } From 9888635e4fb514f070df29255726576f8cbc2a32 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 30 Jun 2026 12:26:58 +0200 Subject: [PATCH 14/21] Minor fixes --- src/appsec/event-data-extractor.ts | 2 +- src/appsec/index.spec.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/appsec/event-data-extractor.ts b/src/appsec/event-data-extractor.ts index 880a1b372..d9b453216 100644 --- a/src/appsec/event-data-extractor.ts +++ b/src/appsec/event-data-extractor.ts @@ -40,7 +40,7 @@ function extractFromApiGatewayV1(event: any): ExtractedHTTPData { const result: ExtractedHTTPData = { headers: headersNoCookies, method: event.httpMethod || "", - path: event.path || event.requestContext?.path || "/", + path: event.requestContext?.path || event.path || "/", query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), body: decodeBody(event.body, event.isBase64Encoded), isBase64Encoded: !!event.isBase64Encoded, diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index 3b7577a33..1dc0c387f 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -132,9 +132,12 @@ describe("AppSec orchestrator", () => { it("should not publish when event is not an HTTP trigger", () => { mockExtract.mockReturnValue(undefined as any); + const span = { setTag: jest.fn() }; + + processAppsecRequest({}, span); - processAppsecRequest({}, {}); expect(mockPublish).not.toHaveBeenCalled(); + expect(span.setTag).toHaveBeenCalledWith("_dd.appsec.unsupported_event_type", 1); }); it("should publish extracted HTTP data to the start-invocation channel", () => { From 160cc17623bf3935de55765b5a49236b2fe7cb75 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Fri, 10 Jul 2026 22:22:01 +0200 Subject: [PATCH 15/21] update dd-trace-js --- package.json | 4 ++-- yarn.lock | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5473b1550..59e7fc856 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@types/node": "^20.12.10", "@types/promise-retry": "^1.1.3", "@types/shimmer": "^1.0.1", - "dd-trace": "^5.109.0", + "dd-trace": "^5.113.0", "jest": "^27.0.1", "mock-fs": "4.14.0", "nock": "13.5.4", @@ -44,7 +44,7 @@ }, "dependencies": { "@aws-crypto/sha256-js": "5.2.0", - "dc-polyfill": "^0.1.3", + "dc-polyfill": "^0.1.11", "promise-retry": "^2.0.1", "serialize-error": "^8.1.0", "shimmer": "1.2.1" diff --git a/yarn.lock b/yarn.lock index 5090dd787..a24a9a257 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1249,6 +1249,10 @@ pprof-format "^2.2.1" source-map "^0.7.4" +"@datadog/pprof@5.15.1": + version "5.15.1" + resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.15.1.tgz#934180721351571ad75b1bce7bde406e6b519aaf" + integrity sha512-4mI750tX6okNROS4YKvGQjyAQ+VqfzDqzysCytOJhL2E2ktK/q4M0PtC7j70tiirGb/fO9fjma3IMNSRLYX+xQ== "@datadog/pprof@5.15.1": version "5.15.1" resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.15.1.tgz#934180721351571ad75b1bce7bde406e6b519aaf" From ef546766e9a0c098e2663c8cfc450b15ccd9d743 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 13 Jul 2026 22:12:09 +0200 Subject: [PATCH 16/21] fix(appsec): process AppSec request before invoking the user handler, not after --- src/index.spec.ts | 22 ++++++++++++++++++++++ src/index.ts | 2 ++ src/trace/listener.spec.ts | 24 +++++++++++++++++++++--- src/trace/listener.ts | 12 ++++++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index d59158067..6c522bbd4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -27,6 +27,12 @@ import { SpanOptions, TracerWrapper } from "./trace/tracer-wrapper"; jest.mock("./metrics/enhanced-metrics"); +const mockProcessAppsecRequest = jest.fn(); +jest.mock("./appsec", () => ({ + ...jest.requireActual("./appsec"), + processAppsecRequest: (...args: any[]) => mockProcessAppsecRequest(...args), +})); + const mockedIncrementErrors = incrementErrorsMetric as jest.Mock; const mockedIncrementInvocations = incrementInvocationsMetric as jest.Mock; const mockedIncrementBatchItemFailures = incrementBatchItemFailureMetric as jest.Mock< @@ -536,6 +542,22 @@ describe("datadog", () => { expect(wrapped[HANDLER_STREAMING]).toBe(undefined); }); + + it("processes the AppSec request before the user handler runs, not after", async () => { + mockProcessAppsecRequest.mockClear(); + const callOrder: string[] = []; + mockProcessAppsecRequest.mockImplementation(() => callOrder.push("appsec-request")); + + const handlerUnderTest = async () => { + callOrder.push("user-handler"); + return { statusCode: 200 }; + }; + + const wrapped = datadog(handlerUnderTest, { forceWrap: true }); + await wrapped({}, mockContext, () => {}); + + expect(callOrder).toEqual(["appsec-request", "user-handler"]); + }); }); describe("sendDistributionMetric", () => { diff --git a/src/index.ts b/src/index.ts index deb8245e4..f5185a85c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -199,6 +199,8 @@ export function datadog( }); } + traceListener.onRequestStart(localEvent); + try { localResult = isResponseStreamFunction ? await promHandler(localEvent, localResponseStream, localContext) diff --git a/src/trace/listener.spec.ts b/src/trace/listener.spec.ts index bf368ea63..6159105bf 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -627,7 +627,7 @@ describe("TraceListener", () => { expect(mockInitAppsec).toHaveBeenCalledTimes(1); }); - it("calls processAppsecRequest with event and span during onEndingInvocation", async () => { + it("calls processAppsecRequest with event and span during onRequestStart", async () => { const mockSetTag = jest.fn(); const mockSpan = { setTag: mockSetTag }; const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); @@ -636,7 +636,7 @@ describe("TraceListener", () => { const listener = new TraceListener(defaultConfig); const event = { httpMethod: "GET", path: "/test" }; await listener.onStartInvocation(event, context as any); - listener.onEndingInvocation(event, {}, false); + listener.onRequestStart(event); expect(mockProcessAppsecRequest).toHaveBeenCalledTimes(1); expect(mockProcessAppsecRequest).toHaveBeenCalledWith(event, mockSpan); @@ -645,6 +645,23 @@ describe("TraceListener", () => { } }); + it("does not call processAppsecRequest during onEndingInvocation", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + const event = { httpMethod: "GET", path: "/test" }; + await listener.onStartInvocation(event, context as any); + listener.onEndingInvocation(event, {}, false); + + expect(mockProcessAppsecRequest).not.toHaveBeenCalled(); + } finally { + currentSpanSpy.mockRestore(); + } + }); + it("calls processAppsecResponse with span, statusCode, and headers during onEndingInvocation", async () => { const mockSetTag = jest.fn(); const mockSpan = { setTag: mockSetTag }; @@ -681,7 +698,7 @@ describe("TraceListener", () => { } }); - it("calls processAppsecRequest before processAppsecResponse", async () => { + it("calls processAppsecRequest (via onRequestStart) before processAppsecResponse (via onEndingInvocation)", async () => { const callOrder: string[] = []; mockProcessAppsecRequest.mockImplementation(() => callOrder.push("request")); mockProcessAppsecResponse.mockImplementation(() => callOrder.push("response")); @@ -693,6 +710,7 @@ describe("TraceListener", () => { try { const listener = new TraceListener(defaultConfig); await listener.onStartInvocation({}, context as any); + listener.onRequestStart({}); listener.onEndingInvocation({}, {}, false); expect(callOrder).toEqual(["request", "response"]); diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 1adc7a8b1..f76d85894 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -168,6 +168,16 @@ export class TraceListener { } } + /** + * onRequestStart runs once the aws.lambda span is active, before the user + * function is invoked. + * + * @param event + */ + public onRequestStart(event: any): void { + processAppsecRequest(event, this.tracerWrapper.currentSpan); + } + /** * onEndingInvocation runs after the user function has returned * but before the wrapped function has returned @@ -184,8 +194,6 @@ export class TraceListener { if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); - processAppsecRequest(event, this.tracerWrapper.currentSpan); - if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); tagObject( From fde79ca467c32a915fdf5ba3fc944854efa79075 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Thu, 16 Jul 2026 14:09:30 +0200 Subject: [PATCH 17/21] fix(appsec): resolve DD_APPSEC_ENABLED through config instead of reading env directly --- src/appsec/index.spec.ts | 87 ++------------------ src/appsec/index.ts | 5 +- src/index.ts | 7 ++ src/trace/context/extractors/kinesis.spec.ts | 1 + src/trace/context/extractors/sns-sqs.spec.ts | 1 + src/trace/context/extractors/sns.spec.ts | 1 + src/trace/context/extractors/sqs.spec.ts | 1 + src/trace/listener.spec.ts | 11 ++- src/trace/listener.ts | 7 +- 9 files changed, 35 insertions(+), 86 deletions(-) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index 1dc0c387f..4d4fa1085 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -18,21 +18,13 @@ import { extractHTTPDataFromEvent } from "./event-data-extractor"; const mockExtract = extractHTTPDataFromEvent as jest.MockedFunction; describe("AppSec orchestrator", () => { - const originalEnv = process.env; - beforeEach(() => { jest.clearAllMocks(); - process.env = { ...originalEnv }; - }); - - afterAll(() => { - process.env = originalEnv; }); describe("initAppsec", () => { - it("should enable when DD_APPSEC_ENABLED is true", () => { - process.env.DD_APPSEC_ENABLED = "true"; - initAppsec(); + it("should enable when called with true", () => { + initAppsec(true); const span = { setTag: jest.fn() }; mockExtract.mockReturnValue({ @@ -46,73 +38,8 @@ describe("AppSec orchestrator", () => { expect(mockPublish).toHaveBeenCalled(); }); - it("should enable when DD_APPSEC_ENABLED is 1", () => { - process.env.DD_APPSEC_ENABLED = "1"; - initAppsec(); - - const span = { setTag: jest.fn() }; - mockExtract.mockReturnValue({ - headers: {}, - method: "GET", - path: "/", - isBase64Encoded: false, - }); - - processAppsecRequest({}, span); - expect(mockPublish).toHaveBeenCalled(); - }); - - it("should enable when DD_APPSEC_ENABLED is TRUE (uppercase)", () => { - process.env.DD_APPSEC_ENABLED = "TRUE"; - initAppsec(); - - const span = { setTag: jest.fn() }; - mockExtract.mockReturnValue({ - headers: {}, - method: "GET", - path: "/", - isBase64Encoded: false, - }); - - processAppsecRequest({}, span); - expect(mockPublish).toHaveBeenCalled(); - }); - - it("should enable when DD_APPSEC_ENABLED is True (mixed case)", () => { - process.env.DD_APPSEC_ENABLED = "True"; - initAppsec(); - - const span = { setTag: jest.fn() }; - mockExtract.mockReturnValue({ - headers: {}, - method: "GET", - path: "/", - isBase64Encoded: false, - }); - - processAppsecRequest({}, span); - expect(mockPublish).toHaveBeenCalled(); - }); - - it("should not enable when DD_APPSEC_ENABLED is not set", () => { - delete process.env.DD_APPSEC_ENABLED; - initAppsec(); - - processAppsecRequest({}, {}); - expect(mockPublish).not.toHaveBeenCalled(); - }); - - it("should not enable when DD_APPSEC_ENABLED is false", () => { - process.env.DD_APPSEC_ENABLED = "false"; - initAppsec(); - - processAppsecRequest({}, {}); - expect(mockPublish).not.toHaveBeenCalled(); - }); - - it("should not enable when DD_APPSEC_ENABLED is FALSE (uppercase)", () => { - process.env.DD_APPSEC_ENABLED = "FALSE"; - initAppsec(); + it("should not enable when called with false", () => { + initAppsec(false); processAppsecRequest({}, {}); expect(mockPublish).not.toHaveBeenCalled(); @@ -121,8 +48,7 @@ describe("AppSec orchestrator", () => { describe("processAppSecRequest", () => { beforeEach(() => { - process.env.DD_APPSEC_ENABLED = "true"; - initAppsec(); + initAppsec(true); }); it("should not publish when span is falsy", () => { @@ -176,8 +102,7 @@ describe("AppSec orchestrator", () => { describe("processAppSecResponse", () => { beforeEach(() => { - process.env.DD_APPSEC_ENABLED = "true"; - initAppsec(); + initAppsec(true); }); it("should not publish when span is falsy", () => { diff --git a/src/appsec/index.ts b/src/appsec/index.ts index 199d7afa2..ae6461d89 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -8,9 +8,8 @@ const endInvocationChannel = dc.channel("datadog:lambda:end-invocation"); let enabled = false; -export function initAppsec(): void { - const envValue = process.env.DD_APPSEC_ENABLED?.toLowerCase(); - enabled = envValue === "true" || envValue === "1"; +export function initAppsec(appsecEnabled: boolean): void { + enabled = appsecEnabled; } export function processAppsecRequest(event: any, span: any): void { diff --git a/src/index.ts b/src/index.ts index f5185a85c..052a4a434 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,7 @@ export const coldStartTraceSkipLibEnvVar = "DD_COLD_START_TRACE_SKIP_LIB"; export const localTestingEnvVar = "DD_LOCAL_TESTING"; export const addSpanPointersEnvVar = "DD_TRACE_AWS_ADD_SPAN_POINTERS"; export const dataStreamsEnabledEnvVar = "DD_DATA_STREAMS_ENABLED"; +export const appsecEnabledEnvVar = "DD_APPSEC_ENABLED"; interface GlobalConfig { /** @@ -100,6 +101,7 @@ export const defaultConfig: Config = { localTesting: false, addSpanPointers: true, dataStreamsEnabled: false, + appsecEnabled: false, } as const; export const _metricsQueue: MetricsQueue = new MetricsQueue(); @@ -432,6 +434,11 @@ function getConfig(userConfig?: Partial): Config { config.dataStreamsEnabled = validEnabledValues.has(result); } + if (userConfig === undefined || userConfig.appsecEnabled === undefined) { + const result = getEnvValue(appsecEnabledEnvVar, "false").toLowerCase(); + config.appsecEnabled = result === "true" || result === "1"; + } + return config; } diff --git a/src/trace/context/extractors/kinesis.spec.ts b/src/trace/context/extractors/kinesis.spec.ts index 87e65c88c..00b7337d8 100644 --- a/src/trace/context/extractors/kinesis.spec.ts +++ b/src/trace/context/extractors/kinesis.spec.ts @@ -39,6 +39,7 @@ describe("KinesisEventTraceExtractor", () => { coldStartTraceSkipLib: "", addSpanPointers: true, dataStreamsEnabled: true, + appsecEnabled: false, }; describe("extract", () => { diff --git a/src/trace/context/extractors/sns-sqs.spec.ts b/src/trace/context/extractors/sns-sqs.spec.ts index 93f519487..b6b6a86bd 100644 --- a/src/trace/context/extractors/sns-sqs.spec.ts +++ b/src/trace/context/extractors/sns-sqs.spec.ts @@ -40,6 +40,7 @@ describe("SNSSQSEventTraceExtractor", () => { coldStartTraceSkipLib: "", addSpanPointers: true, dataStreamsEnabled: true, + appsecEnabled: false, }; describe("extract", () => { diff --git a/src/trace/context/extractors/sns.spec.ts b/src/trace/context/extractors/sns.spec.ts index 3ac18a99b..d68521cd3 100644 --- a/src/trace/context/extractors/sns.spec.ts +++ b/src/trace/context/extractors/sns.spec.ts @@ -41,6 +41,7 @@ describe("SNSEventTraceExtractor", () => { coldStartTraceSkipLib: "", addSpanPointers: true, dataStreamsEnabled: true, + appsecEnabled: false, }; describe("extract", () => { diff --git a/src/trace/context/extractors/sqs.spec.ts b/src/trace/context/extractors/sqs.spec.ts index bbf9cdd85..f6eabdbb7 100644 --- a/src/trace/context/extractors/sqs.spec.ts +++ b/src/trace/context/extractors/sqs.spec.ts @@ -83,6 +83,7 @@ describe("SQSEventTraceExtractor", () => { coldStartTraceSkipLib: "", addSpanPointers: true, dataStreamsEnabled: true, + appsecEnabled: false, }; describe("extract", () => { diff --git a/src/trace/listener.spec.ts b/src/trace/listener.spec.ts index 6159105bf..7628f0275 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -81,6 +81,7 @@ describe("TraceListener", () => { coldStartTraceSkipLib: "", addSpanPointers: true, dataStreamsEnabled: true, + appsecEnabled: true, }; const context = { invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789101:function:my-lambda", @@ -620,11 +621,19 @@ describe("TraceListener", () => { }); describe("AppSec integration", () => { - it("calls initAppsec on start invocation", async () => { + it("calls initAppsec with the resolved appsecEnabled config value on start invocation", async () => { const listener = new TraceListener(defaultConfig); await listener.onStartInvocation({}, context as any); expect(mockInitAppsec).toHaveBeenCalledTimes(1); + expect(mockInitAppsec).toHaveBeenCalledWith(defaultConfig.appsecEnabled); + }); + + it("calls initAppsec with false when appsecEnabled is disabled in config", async () => { + const listener = new TraceListener({ ...defaultConfig, appsecEnabled: false }); + await listener.onStartInvocation({}, context as any); + + expect(mockInitAppsec).toHaveBeenCalledWith(false); }); it("calls processAppsecRequest with event and span during onRequestStart", async () => { diff --git a/src/trace/listener.ts b/src/trace/listener.ts index f76d85894..6aea46a4e 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -91,6 +91,11 @@ export interface TraceConfig { * @default false */ dataStreamsEnabled: boolean; + /** + * Whether to enable AppSec (In-App WAF) request/response analysis. + * @default false + */ + appsecEnabled: boolean; } export class TraceListener { @@ -117,7 +122,7 @@ export class TraceListener { } public async onStartInvocation(event: any, context: Context) { - initAppsec(); + initAppsec(this.config.appsecEnabled); const tracerInitialized = this.tracerWrapper.isTracerAvailable; if (this.config.injectLogContext) { From c743a313797b76d60e3338a1f5bda9d611e172ff Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Thu, 16 Jul 2026 20:39:22 +0200 Subject: [PATCH 18/21] chore: regenerate yarn.lock for dd-trace ^5.113.0 after rebase --- yarn.lock | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/yarn.lock b/yarn.lock index a24a9a257..7d55c31ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1249,10 +1249,6 @@ pprof-format "^2.2.1" source-map "^0.7.4" -"@datadog/pprof@5.15.1": - version "5.15.1" - resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.15.1.tgz#934180721351571ad75b1bce7bde406e6b519aaf" - integrity sha512-4mI750tX6okNROS4YKvGQjyAQ+VqfzDqzysCytOJhL2E2ktK/q4M0PtC7j70tiirGb/fO9fjma3IMNSRLYX+xQ== "@datadog/pprof@5.15.1": version "5.15.1" resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.15.1.tgz#934180721351571ad75b1bce7bde406e6b519aaf" @@ -2971,15 +2967,10 @@ dc-polyfill@^0.1.11: resolved "https://registry.yarnpkg.com/dc-polyfill/-/dc-polyfill-0.1.11.tgz#3efa792147f3b5224b8a9274905b1e98fe82a856" integrity sha512-TyyeGcjx0YeThAI9fTFtgsvj5qd4R+aGfVmXiUhevbgzWFDr7IK4tv4YjE6jaGzLHQTchk4h7DHdr5q4WGgaZw== -dc-polyfill@^0.1.3: - version "0.1.9" - resolved "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.9.tgz" - integrity sha512-D5mJThEEk9hf+CJPwTf9JFsrWdlWp8Pccjxkhf7uUT/E/cU9Mx3ebWe2Bz2OawRmJ6WS9eaDPBkeBE4uOKq9uw== - -dd-trace@^5.109.0: - version "5.114.0" - resolved "https://registry.yarnpkg.com/dd-trace/-/dd-trace-5.114.0.tgz#b7ab0b7d5c6e22b7bf1830b6504b4d386e9a432f" - integrity sha512-RBQGHdD5eCGqquuBTqWpNakA5WgMjecNNG+rniWYpdSXuqR/i9gBDzflHPGT/mb46xdPo8hQngkRTbKmJp1V0g== +dd-trace@^5.113.0: + version "5.115.0" + resolved "https://registry.yarnpkg.com/dd-trace/-/dd-trace-5.115.0.tgz#9163c107cbb73b5d3584c733ebec7d71b212b8b9" + integrity sha512-bZpHDDUmhSo4nQRr1j8VFdLOYK+r7XquK3wS6TqYFvkdH0hhDgmOUmypQG7i1/lEcRFQBY+Z76Tyio8/LAl0sw== dependencies: dc-polyfill "^0.1.11" import-in-the-middle "^3.3.1" From c79752d6e85899148fc49de93a1199c21fd7d531 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Fri, 17 Jul 2026 12:22:19 +0200 Subject: [PATCH 19/21] refactor(appsec): gate on config.appsecEnabled at call sites, drop module-level enabled state --- src/appsec/index.spec.ts | 34 +--------------------------------- src/appsec/index.ts | 10 ++-------- src/index.spec.ts | 2 +- src/trace/listener.spec.ts | 38 ++++++++++++++++++++++++++------------ src/trace/listener.ts | 13 +++++++------ 5 files changed, 37 insertions(+), 60 deletions(-) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index 4d4fa1085..6bcbce432 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -7,7 +7,7 @@ jest.mock("dc-polyfill", () => ({ })), })); -import { initAppsec, processAppsecRequest, processAppsecResponse } from "./index"; +import { processAppsecRequest, processAppsecResponse } from "./index"; jest.mock("./event-data-extractor", () => ({ extractHTTPDataFromEvent: jest.fn(), @@ -22,35 +22,7 @@ describe("AppSec orchestrator", () => { jest.clearAllMocks(); }); - describe("initAppsec", () => { - it("should enable when called with true", () => { - initAppsec(true); - - const span = { setTag: jest.fn() }; - mockExtract.mockReturnValue({ - headers: { host: "example.com" }, - method: "GET", - path: "/", - isBase64Encoded: false, - }); - - processAppsecRequest({}, span); - expect(mockPublish).toHaveBeenCalled(); - }); - - it("should not enable when called with false", () => { - initAppsec(false); - - processAppsecRequest({}, {}); - expect(mockPublish).not.toHaveBeenCalled(); - }); - }); - describe("processAppSecRequest", () => { - beforeEach(() => { - initAppsec(true); - }); - it("should not publish when span is falsy", () => { processAppsecRequest({}, null); expect(mockPublish).not.toHaveBeenCalled(); @@ -101,10 +73,6 @@ describe("AppSec orchestrator", () => { }); describe("processAppSecResponse", () => { - beforeEach(() => { - initAppsec(true); - }); - it("should not publish when span is falsy", () => { processAppsecResponse(null, "200"); expect(mockPublish).not.toHaveBeenCalled(); diff --git a/src/appsec/index.ts b/src/appsec/index.ts index ae6461d89..a3d331739 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -6,14 +6,8 @@ import { extractHTTPDataFromEvent } from "./event-data-extractor"; const startInvocationChannel = dc.channel("datadog:lambda:start-invocation"); const endInvocationChannel = dc.channel("datadog:lambda:end-invocation"); -let enabled = false; - -export function initAppsec(appsecEnabled: boolean): void { - enabled = appsecEnabled; -} - export function processAppsecRequest(event: any, span: any): void { - if (!enabled || !span || !startInvocationChannel.hasSubscribers) return; + if (!span || !startInvocationChannel.hasSubscribers) return; const httpData = extractHTTPDataFromEvent(event); if (!httpData) { @@ -37,7 +31,7 @@ export function processAppsecRequest(event: any, span: any): void { } export function processAppsecResponse(span: any, statusCode?: string, responseHeaders?: Record): void { - if (!enabled || !span || !endInvocationChannel.hasSubscribers) return; + if (!span || !endInvocationChannel.hasSubscribers) return; endInvocationChannel.publish({ span, diff --git a/src/index.spec.ts b/src/index.spec.ts index 6c522bbd4..59f76163d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -553,7 +553,7 @@ describe("datadog", () => { return { statusCode: 200 }; }; - const wrapped = datadog(handlerUnderTest, { forceWrap: true }); + const wrapped = datadog(handlerUnderTest, { forceWrap: true, appsecEnabled: true }); await wrapped({}, mockContext, () => {}); expect(callOrder).toEqual(["appsec-request", "user-handler"]); diff --git a/src/trace/listener.spec.ts b/src/trace/listener.spec.ts index 7628f0275..a44d40340 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -12,12 +12,10 @@ import { } from "./context/extractor"; import { TracerWrapper } from "./tracer-wrapper"; -const mockInitAppsec = jest.fn(); const mockProcessAppsecRequest = jest.fn(); const mockProcessAppsecResponse = jest.fn(); jest.mock("../appsec", () => ({ - initAppsec: (...args: any[]) => mockInitAppsec(...args), processAppsecRequest: (...args: any[]) => mockProcessAppsecRequest(...args), processAppsecResponse: (...args: any[]) => mockProcessAppsecResponse(...args), })); @@ -98,7 +96,6 @@ describe("TraceListener", () => { }; beforeEach(() => { wrapSpy.mockClear(); - mockInitAppsec.mockClear(); mockProcessAppsecRequest.mockClear(); mockProcessAppsecResponse.mockClear(); mockController.mockSpanContext = undefined; @@ -621,19 +618,36 @@ describe("TraceListener", () => { }); describe("AppSec integration", () => { - it("calls initAppsec with the resolved appsecEnabled config value on start invocation", async () => { - const listener = new TraceListener(defaultConfig); - await listener.onStartInvocation({}, context as any); + it("does not call processAppsecRequest during onRequestStart when appsecEnabled is false", async () => { + const mockSpan = { setTag: jest.fn() }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener({ ...defaultConfig, appsecEnabled: false }); + const event = { httpMethod: "GET", path: "/test" }; + await listener.onStartInvocation(event, context as any); + listener.onRequestStart(event); - expect(mockInitAppsec).toHaveBeenCalledTimes(1); - expect(mockInitAppsec).toHaveBeenCalledWith(defaultConfig.appsecEnabled); + expect(mockProcessAppsecRequest).not.toHaveBeenCalled(); + } finally { + currentSpanSpy.mockRestore(); + } }); - it("calls initAppsec with false when appsecEnabled is disabled in config", async () => { - const listener = new TraceListener({ ...defaultConfig, appsecEnabled: false }); - await listener.onStartInvocation({}, context as any); + it("does not call processAppsecResponse during onEndingInvocation when appsecEnabled is false", async () => { + const mockSpan = { setTag: jest.fn() }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); - expect(mockInitAppsec).toHaveBeenCalledWith(false); + try { + const listener = new TraceListener({ ...defaultConfig, appsecEnabled: false }); + const result = { statusCode: 200, headers: { "content-type": "application/json" } }; + await listener.onStartInvocation({}, context as any); + listener.onEndingInvocation({}, result, false); + + expect(mockProcessAppsecResponse).not.toHaveBeenCalled(); + } finally { + currentSpanSpy.mockRestore(); + } }); it("calls processAppsecRequest with event and span during onRequestStart", async () => { diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 6aea46a4e..91c364ffc 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -28,7 +28,7 @@ import { import { XrayService } from "./xray-service"; import { AUTHORIZING_REQUEST_ID_HEADER } from "./context/extractors/http"; import { getSpanPointerAttributes, SpanPointerAttributes } from "../utils/span-pointers"; -import { initAppsec, processAppsecRequest, processAppsecResponse } from "../appsec"; +import { processAppsecRequest, processAppsecResponse } from "../appsec"; export type TraceExtractor = (event: any, context: Context) => Promise | TraceContext; @@ -122,8 +122,6 @@ export class TraceListener { } public async onStartInvocation(event: any, context: Context) { - initAppsec(this.config.appsecEnabled); - const tracerInitialized = this.tracerWrapper.isTracerAvailable; if (this.config.injectLogContext) { patchConsole(console, this.contextService); @@ -180,6 +178,7 @@ export class TraceListener { * @param event */ public onRequestStart(event: any): void { + if (!this.config.appsecEnabled) return; processAppsecRequest(event, this.tracerWrapper.currentSpan); } @@ -233,9 +232,11 @@ export class TraceListener { // Always clear the tree to prevent memory leaks, even if we skip span creation clearTraceTree(); } - const responseStatusCode = result?.statusCode?.toString(); - const responseHeaders = result?.headers as Record | undefined; - processAppsecResponse(this.tracerWrapper.currentSpan, responseStatusCode, responseHeaders); + if (this.config.appsecEnabled) { + const responseStatusCode = result?.statusCode?.toString(); + const responseHeaders = result?.headers as Record | undefined; + processAppsecResponse(this.tracerWrapper.currentSpan, responseStatusCode, responseHeaders); + } if (this.triggerTags) { const statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction); From 7eabe4a2b03c9ebb126593f62b112e8d2ec58fb3 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Fri, 17 Jul 2026 12:58:32 +0200 Subject: [PATCH 20/21] refactor(appsec): extract response status/headers inside processAppsecResponse --- src/appsec/index.spec.ts | 10 +++++----- src/appsec/index.ts | 6 +++--- src/trace/listener.spec.ts | 21 ++------------------- src/trace/listener.ts | 4 +--- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index 6bcbce432..51b723167 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -74,14 +74,14 @@ describe("AppSec orchestrator", () => { describe("processAppSecResponse", () => { it("should not publish when span is falsy", () => { - processAppsecResponse(null, "200"); + processAppsecResponse(null, { statusCode: 200 }); expect(mockPublish).not.toHaveBeenCalled(); }); - it("should publish response data to the end-invocation channel", () => { + it("should extract status code and headers from the result and publish them", () => { const span = { setTag: jest.fn() }; - processAppsecResponse(span, "200", { "content-type": "application/json" }); + processAppsecResponse(span, { statusCode: 200, headers: { "content-type": "application/json" } }); expect(mockPublish).toHaveBeenCalledWith({ span, @@ -90,10 +90,10 @@ describe("AppSec orchestrator", () => { }); }); - it("should publish with undefined statusCode and headers", () => { + it("should publish with undefined statusCode and headers when result has none", () => { const span = { setTag: jest.fn() }; - processAppsecResponse(span); + processAppsecResponse(span, {}); expect(mockPublish).toHaveBeenCalledWith({ span, diff --git a/src/appsec/index.ts b/src/appsec/index.ts index a3d331739..ee3d86873 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -30,12 +30,12 @@ export function processAppsecRequest(event: any, span: any): void { }); } -export function processAppsecResponse(span: any, statusCode?: string, responseHeaders?: Record): void { +export function processAppsecResponse(span: any, result: any): void { if (!span || !endInvocationChannel.hasSubscribers) return; endInvocationChannel.publish({ span, - statusCode, - responseHeaders, + statusCode: result?.statusCode?.toString(), + responseHeaders: result?.headers as Record | undefined, }); } diff --git a/src/trace/listener.spec.ts b/src/trace/listener.spec.ts index a44d40340..5e1399dd7 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -685,7 +685,7 @@ describe("TraceListener", () => { } }); - it("calls processAppsecResponse with span, statusCode, and headers during onEndingInvocation", async () => { + it("calls processAppsecResponse with the span and the raw result during onEndingInvocation", async () => { const mockSetTag = jest.fn(); const mockSpan = { setTag: mockSetTag }; const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); @@ -698,24 +698,7 @@ describe("TraceListener", () => { listener.onEndingInvocation(event, result, false); expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); - expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, "200", { "content-type": "application/json" }); - } finally { - currentSpanSpy.mockRestore(); - } - }); - - it("calls processAppsecResponse with undefined statusCode and headers when result has none", async () => { - const mockSetTag = jest.fn(); - const mockSpan = { setTag: mockSetTag }; - const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); - - try { - const listener = new TraceListener(defaultConfig); - await listener.onStartInvocation({}, context as any); - listener.onEndingInvocation({}, {}, false); - - expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); - expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, undefined, undefined); + expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, result); } finally { currentSpanSpy.mockRestore(); } diff --git a/src/trace/listener.ts b/src/trace/listener.ts index 91c364ffc..f3c857363 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -233,9 +233,7 @@ export class TraceListener { clearTraceTree(); } if (this.config.appsecEnabled) { - const responseStatusCode = result?.statusCode?.toString(); - const responseHeaders = result?.headers as Record | undefined; - processAppsecResponse(this.tracerWrapper.currentSpan, responseStatusCode, responseHeaders); + processAppsecResponse(this.tracerWrapper.currentSpan, result); } if (this.triggerTags) { From a0081d9bcb57476017347ef7f4ed4c782cea6454 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Fri, 17 Jul 2026 13:23:15 +0200 Subject: [PATCH 21/21] style: remove leftover blank lines in listener.ts --- src/trace/listener.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/trace/listener.ts b/src/trace/listener.ts index f3c857363..1483a6adc 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -197,7 +197,6 @@ export class TraceListener { // so we won't crash user code. if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); - if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); tagObject( @@ -235,7 +234,6 @@ export class TraceListener { if (this.config.appsecEnabled) { processAppsecResponse(this.tracerWrapper.currentSpan, result); } - if (this.triggerTags) { const statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction);