From 58e86a2693578025f90fcf8d71ae614568986de6 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:42:55 +0100 Subject: [PATCH 01/10] 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 760b7ab82..cf1a2dfbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,13 @@ RUN cp -r dist /nodejs/node_modules/datadog-lambda-js RUN cp ./src/runtime/module_importer.js /nodejs/node_modules/datadog-lambda-js/runtime 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 # Copy the dependencies to the modules folder @@ -46,6 +47,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 8e48691ee19f384d550e150955b06b958a56e2fd Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:44:45 +0100 Subject: [PATCH 02/10] 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 f068c5352..37c386f2b 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 bad7953c4e559dfb1c656bc4d71fe791c5838c5b Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 08:03:47 +0100 Subject: [PATCH 03/10] 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 843657f6145c8bbc14c2aeb8a95527cf16ec31b9 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:10:59 +0100 Subject: [PATCH 04/10] 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 a79197af03a76e0b53a8b771ef8d6e5329830c33 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:26:27 +0100 Subject: [PATCH 05/10] 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 95423e18f6e5b7298b069bf9ef3fc4fff558265d Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 17:44:09 +0200 Subject: [PATCH 06/10] 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 246a907f8..55e33d7c5 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; @@ -608,4 +621,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 37c386f2b..ba63abba8 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 81b0d53d7e41ecefe14b995009e92222dfb94a00 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 17:57:50 +0200 Subject: [PATCH 07/10] 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 3a74207a75d941c83682803d655f007a7bcd18fd Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:03:56 +0200 Subject: [PATCH 08/10] 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 a35656b52..27801eb98 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": "*", "@opentelemetry/api": "*", "@opentelemetry/api-logs": "*", 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 57dab58121cf365b461d75a6b11071ffd5eecfb7 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:04:46 +0200 Subject: [PATCH 09/10] 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 5caa9f07fde44bae0ec23c07028f531f39a62afb Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Tue, 21 Apr 2026 18:19:56 +0200 Subject: [PATCH 10/10] Updated yarn.lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 6af142561..29d613532 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,7 +1156,7 @@ resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.9.2.tgz#d7a0193ab656bd9cc40649f300ef6c54d9bea52d" integrity sha512-grOerTYuU3wHuFIOBGg3jB144A3KEthEdVEL3meeiXYo7E7fBXXGRgAOwVE42VXFXfl0r8kDKCL7KupBc511tg== -"@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==